fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716)

## 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 <noreply@paperclip.ing>
This commit is contained in:
TonioandPaperclip authored and GitHub committed 2026-11-05 01:23:53 -08:00
1 parent beb73381ca
commit 56517dda6c
60 files changed
+1123 -325

No files matched your search

+26 -1
View File
@@ -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
+2 -2
View File
@@ -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`
+16 -6
View File
@@ -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");
+2 -2
View File
@@ -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();
@@ -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);
});
@@ -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");
});
+1
View File
@@ -36,6 +36,7 @@ export function CollectionToolbar({
return (
<div
data-slot="collection-toolbar"
data-canonical-toolbar="collection"
className={cn("flex flex-col gap-2", className)}
role="toolbar"
aria-label={ariaLabel}
+7 -3
View File
@@ -1,9 +1,12 @@
import { Plus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
interface EmptyStateProps {
icon: LucideIcon;
/** Large decorative artwork; shares hover/focus with the action below. */
graphic?: ReactNode;
/** Optional bold heading rendered above the message. */
title?: string;
message: string;
@@ -17,6 +20,7 @@ interface EmptyStateProps {
export function EmptyState({
icon: Icon,
graphic,
title,
message,
description,
@@ -25,10 +29,10 @@ export function EmptyState({
hideActionIcon = false,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="bg-muted/50 p-4 mb-4">
<div className={graphic ? "empty-state-artwork flex flex-col items-center justify-center gap-5 py-16 text-center" : "flex flex-col items-center justify-center py-16 text-center"}>
{graphic ? <div className="empty-state-graphic" aria-hidden="true">{graphic}</div> : <div className="bg-muted/50 p-4 mb-4">
<Icon className="h-10 w-10 text-muted-foreground/50" />
</div>
</div>}
{title ? (
<>
<p className="text-base font-semibold text-foreground mb-1.5">{title}</p>
+11 -2
View File
@@ -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<string>;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise<void>;
@@ -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 = (
<textarea
ref={inputRef}
value={draft}
aria-invalid={autosaveState === "error" || undefined}
aria-description={autosaveState === "error" ? autosave.label ?? undefined : undefined}
rows={1}
aria-label={placeholder}
onChange={(e) => {
changeDraft(e.target.value);
autoSize(e.target);
@@ -527,6 +530,12 @@ function OwnedInlineEditor({
)}
/>
);
return fitContent ? (
<span className="inline-editor-fit">
<span aria-hidden className={cn("invisible whitespace-pre", pad, className)}>{draft || " "}</span>
{editor}
</span>
) : editor;
}
// Use div instead of Tag when rendering markdown to avoid invalid nesting
+1 -1
View File
@@ -64,7 +64,7 @@ export function ProjectsToolbar({ query, onQueryChange, sortField, sortDir, onSo
<div className="design-toolbar-actions">
<Button size="sm" variant="outline" data-toolbar-primary onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
Create project
</Button>
</div>
</div>;
+11 -5
View File
@@ -1,5 +1,5 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState } from "react";
import { useState, type ReactElement } from "react";
import type { Agent } from "@paperclipai/shared";
import {
Popover,
@@ -18,6 +18,8 @@ export function ReportsToPicker({
excludeAgentIds = [],
disabledEmptyLabel = "Reports to: N/A (CEO)",
chooseLabel = "Reports to...",
trigger,
disabledAgentIds = [],
}: {
agents: Agent[];
value: string | null;
@@ -26,6 +28,8 @@ export function ReportsToPicker({
excludeAgentIds?: string[];
disabledEmptyLabel?: string;
chooseLabel?: string;
trigger?: ReactElement;
disabledAgentIds?: string[];
}) {
const [open, setOpen] = useState(false);
const exclude = new Set(excludeAgentIds);
@@ -39,7 +43,7 @@ export function ReportsToPicker({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
{trigger ?? <button
type="button"
className={cn(
"inline-flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
@@ -73,9 +77,9 @@ export function ReportsToPicker({
</span>
</>
)}
</button>
</button>}
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<PopoverContent className={trigger ? "w-64 max-h-80 overflow-y-auto p-1" : "w-48 p-1"} align="start" aria-label="Choose manager">
<button
type="button"
className={cn(
@@ -106,8 +110,10 @@ export function ReportsToPicker({
<button
type="button"
key={a.id}
disabled={disabledAgentIds.includes(a.id)}
title={disabledAgentIds.includes(a.id) ? "Would create a reporting loop" : undefined}
className={cn(
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden",
"flex items-center gap-2 w-full min-w-0 px-2 py-1.5 text-xs rounded hover:bg-accent/50 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed",
a.id === value && "bg-accent",
)}
onClick={() => {
@@ -55,7 +55,7 @@ describe("pending agent roster display", () => {
return agent;
}
const editButton = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(b => b.textContent?.includes("Edit connection & model"))!;
const editButton = () => [...document.querySelectorAll<HTMLButtonElement>('[aria-label="Bulk actions"] button')].find(b => b.textContent?.includes("Edit connection & model"))!;
it("loads complete current agents before opening bulk edits from a restored roster", async () => {
const display = renderEditable();
@@ -101,7 +101,7 @@ describe("pending agent roster display", () => {
api.get.mockReturnValue(new Promise<Agent>((resolve, fail) => { finish = resolve; reject = fail; }));
await act(async () => editButton().click());
expect(api.get).toHaveBeenCalledTimes(1);
await act(async () => container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());
await act(async () => document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());
await act(async () => {
if (outcome === "success") finish(display);
else reject(new TypeError("Failed to fetch"));
@@ -130,7 +130,7 @@ describe("pending agent roster display", () => {
it("retains the pending harness label while keeping the placeholder unavailable for bulk editing", () => {
const row = render({ setupAdapterPending: true,
setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } });
expect([...row.querySelectorAll("td")].slice(2).map(cell => cell.textContent)).toEqual([
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([
"Google Antigravity", "Not configured", "Not available yet",
]);
const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
@@ -141,7 +141,7 @@ describe("pending agent roster display", () => {
it("preserves ordinary process display without a saved setup choice", () => {
const row = render({ setupAdapterPending: true });
expect([...row.querySelectorAll("td")].slice(2).map(cell => cell.textContent)).toEqual([
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([
"Process", "Managed externally", "Harness default",
]);
const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
+24 -13
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Filter, Pencil, X } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
@@ -9,6 +10,7 @@ import { aiConnectionsApi } from "@/api/ai-connections";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentDisabledReason, bulkAgentHarness, bulkConnectionName } from "@/lib/bulk-agent-settings";
import { pendingAgentSetupDisplayAdapter } from "@/lib/agent-setup-choice";
import { AgentStatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -17,8 +19,10 @@ import { cn } from "@/lib/utils";
const AgentBulkSettingsSheet = lazy(() => import("./AgentBulkSettingsSheet").then(m => ({ default: m.AgentBulkSettingsSheet })));
/** Shared by both roster layouts. Mount with a company key to isolate selection. */
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent }: {
export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent, renderChat, renderStatus }: {
companyId: string; agents: Agent[]; visibleAgents: Agent[]; renderAgent: (agent: Agent) => ReactNode;
renderChat?: (agent: Agent) => ReactNode;
renderStatus?: (agent: Agent) => ReactNode;
}) {
const client = useQueryClient();
const openingRef = useRef(false);
@@ -36,12 +40,16 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
const visible = visibleAgents.filter(a => filter === "all" || bulkAgentHarness(a) === filter);
const selectable = visible.filter(a => !bulkAgentDisabledReason(a));
const selectedAgents = agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a));
const selectedAgents = useMemo(() => agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a)), [agents, selected]);
const [lastSelection, setLastSelection] = useState<Agent[]>([]);
useEffect(() => { if (selectedAgents.length) setLastSelection(selectedAgents); }, [selectedAgents]);
// Retain the card contents while it drops out, so its width does not collapse mid-animation.
const actionAgents = selectedAgents.length ? selectedAgents : lastSelection;
const harnesses = [...new Set(agents.map(bulkAgentHarness))].sort((a, b) => getAdapterLabel(a).localeCompare(getAdapterLabel(b)));
const groups = [...new Set(selectedAgents.map(bulkAgentHarness))];
const groups = [...new Set(actionAgents.map(bulkAgentHarness))];
const all = selectable.length > 0 && selectable.every(a => selected.includes(a.id));
const some = selectable.some(a => selected.includes(a.id));
const hidden = selectedAgents.filter(a => !visible.includes(a)).length;
const hidden = actionAgents.filter(a => !visible.includes(a)).length;
const toggleAll = () => changeSelection(all ? selected.filter(id => !selectable.some(a => a.id === id)) : [...new Set([...selected, ...selectable.map(a => a.id)])]);
async function openEditor(harness: string) {
@@ -68,7 +76,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
}
return <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className="flex flex-wrap items-center justify-between gap-3">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={filter === "all" ? "Filter" : `Filter: ${getAdapterLabel(filter)}`}
@@ -93,28 +101,31 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent
</Popover>
<span className="text-xs text-muted-foreground">{visible.length} {visible.length === 1 ? "agent" : "agents"}</span>
</div>
{selectedAgents.length > 0 && <div role="region" aria-label="Bulk actions" className="sticky top-0 z-10 flex flex-wrap items-center gap-3 rounded-md border bg-muted p-3">
<span className="text-sm font-medium">{selectedAgents.length} selected</span>
{createPortal(<div role="region" aria-label="Bulk actions" aria-hidden={selectedAgents.length === 0} inert={selectedAgents.length === 0}
data-state={selectedAgents.length > 0 ? "open" : "closed"} className="agent-selection-bar flex flex-wrap items-center gap-3 rounded-lg border bg-background p-3">
<span className="text-sm font-medium">{actionAgents.length} selected</span>
{hidden > 0 && <span className="text-xs text-muted-foreground">{hidden} hidden by filters</span>}
{groups.length > 1 && <span className="text-xs text-muted-foreground">Choose a harness to edit</span>}
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">{groups.map(h => <Button key={h} size="sm" variant="outline" disabled={opening} onClick={() => void openEditor(h)}>
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${selectedAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
<Pencil className="size-3.5" aria-hidden />{groups.length === 1 ? "Edit connection & model" : `Edit ${actionAgents.filter(a => bulkAgentHarness(a) === h).length} ${getAdapterLabel(h)}`}
</Button>)}<Button size="icon-sm" variant="ghost" aria-label="Clear selection" onClick={() => changeSelection([])}><X className="size-4" /></Button></div>
</div>}
</div>, document.body)}
{opening && <p role="status" className="text-sm text-muted-foreground">Loading current agent settings…</p>}
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
{accounts.error && <p className="text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={() => void accounts.refetch()}>Retry</Button></p>}
<div className="overflow-x-auto"><table aria-label="Agent roster" className="w-full text-left text-sm">
<div className="overflow-x-auto"><table aria-label="Agent roster" className="agent-roster w-full text-left text-sm">
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr>
<th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={all ? true : some ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th>
<th>Agent</th><th>Harness</th><th>Connection</th><th>Default model</th>
<th scope="col">Agent</th>{renderChat && <th scope="col"><span className="sr-only">Chat</span></th>}<th scope="col">Status</th><th scope="col">Harness</th><th scope="col">Connection</th><th scope="col">Default model</th>
</tr></thead>
<tbody>{visible.map(agent => {
const pendingSetup = pendingAgentSetupDisplayAdapter(agent);
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 [&_td]:px-3">
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 hover:bg-accent/50 focus-within:bg-accent/50 [&_td]:px-3 [&_td]:align-middle">
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
{renderChat && <td>{renderChat(agent)}</td>}
<td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td>
<td className="whitespace-nowrap text-xs">{getAdapterLabel(pendingSetup ?? bulkAgentHarness(agent))}</td>
<td className="text-xs">{pendingSetup ? "Not configured" : ["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}</td>
<td className="font-mono text-xs">{pendingSetup ? "Not available yet" : agentDefaultModel(agent) ?? "Harness default"}</td>
@@ -9,7 +9,7 @@ import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentHarness, bulkConnectionName, mapBulkAgents, resolveBulkConnection, sameBinding } from "@/lib/bulk-agent-settings";
import { nativeTaskModelCatalogOptions } from "@/lib/native-model-catalog";
import { queryKeys } from "@/lib/queryKeys";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentIdentityChipContent, agentIdentityChipClass } from "./AgentIdentityChip";
import { ModelDropdown } from "@/components/AgentConfigForm";
import { AiConnectionField, aiProviderForAdapter } from "@/components/ai-connections/AiConnectionField";
import { bindingProblem } from "@/components/ai-connections/model";
@@ -134,7 +134,7 @@ export function AgentBulkSettingsSheet({ companyId, agents, totalSelected, onClo
return <Sheet open onOpenChange={open => { if (!open) close(); }}><SheetContent className="w-full sm:max-w-lg" showCloseButton={!saving} onEscapeKeyDown={event => { if (savingRef.current) event.preventDefault(); }} onInteractOutside={event => { if (savingRef.current) event.preventDefault(); }}>
<SheetHeader className="px-6 pt-6 pb-0"><SheetTitle>Update {agents.length} {label} {agents.length === 1 ? "agent" : "agents"}</SheetTitle><SheetDescription>{totalSelected > agents.length ? `${otherSelected} other selected ${otherSelected === 1 ? "agent" : "agents"} won’t change.` : "Change their connection, default model, or both."}</SheetDescription></SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-2">
<div className="flex flex-wrap gap-2" aria-label="Agents to update">{agents.map(a => <span key={a.id} className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs"><AgentAvatar agent={a} size={20} />{a.name}</span>)}</div>
<div className="flex flex-wrap gap-2" aria-label="Agents to update">{agents.map(a => <span key={a.id} className={agentIdentityChipClass}><AgentIdentityChipContent agent={a} /></span>)}</div>
{failures && <div role="alert" className="space-y-2 rounded-md border border-destructive/30 p-3 text-sm"><p className="font-medium">{updatedIds.length} updated · {failures.length} couldn’t be saved</p>{failures.map(f => <p key={f.agent.id} className="text-muted-foreground">{f.agent.name}: {f.message}</p>)}<p className="text-xs text-muted-foreground">Retry updates only the remaining agents. Close and reopen to review changed settings.</p></div>}
<fieldset disabled={saving || failures !== null} className="flex flex-col gap-6 disabled:opacity-60">
<section className="space-y-3"><label className="flex cursor-pointer items-start gap-3"><Checkbox checked={changeConnection} disabled={!aiProviderForAdapter(harness)} onCheckedChange={value => setChangeConnection(value === true)} aria-label="Change connection" className="mt-0.5" /><span className="space-y-1"><span className="block text-sm font-medium">Change connection</span><span className="block text-xs text-muted-foreground">{currentConnections.length === 1 ? currentConnections[0] : `${currentConnections.length} different connections`}{!changeConnection && " · Keep current"}</span></span></label>
@@ -0,0 +1,8 @@
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
export const agentIdentityChipClass = "agent-identity-chip inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs";
export function AgentIdentityChipContent({ agent }: { agent: Agent }) {
return <><AgentAvatar agent={agent} size={20} /><span className="truncate">{agent.name}</span></>;
}
@@ -0,0 +1,12 @@
import { ArtifactFront } from "./ArtifactFront";
import { ArtifactStack } from "./ArtifactStack";
import type { ArtifactKind, ArtifactSubject } from "./kinds";
import brandPreview from "./assets/empty-brand.svg";
/** Reuses the full-size artifact artwork and its hover fan for empty collections. */
export function ArtifactEmptyGraphic({ kind = "file", stack = false }: { kind?: ArtifactKind; stack?: boolean }) {
const subject: ArtifactSubject = stack
? { kind: "image", title: "", thumbnailUrl: brandPreview, aspect: 1.5 }
: { kind, title: "", ...(kind === "document" ? { ext: "md" } : {}) };
return stack ? <ArtifactStack front={subject} /> : <ArtifactFront subject={subject} />;
}
@@ -50,7 +50,7 @@ export function ArtifactFront({
case "media-fallback":
return (
<div data-artifact-front className="shrink-0" style={style}>
<IconSheet subject={subject} play />
<IconSheet subject={subject} play={subject.kind !== "image"} />
</div>
);
default:
@@ -66,6 +66,7 @@ describe("the Artifacts page tiles", () => {
expect(print?.style.height).toBe("163.33px");
const front = container.querySelector<HTMLElement>("[data-artifact-front]");
expect(front).not.toBeNull();
expect(container.querySelectorAll("[data-artifact-front]")).toHaveLength(1);
expect(front?.contains(print)).toBe(true);
expect(front?.style.position).toBe("absolute");
expect(print?.style.position).toBe("");
@@ -83,6 +84,7 @@ describe("the Artifacts page tiles", () => {
);
const front = container.querySelector("[data-artifact-front]");
expect(front).not.toBeNull();
expect(container.querySelectorAll("[data-artifact-front]")).toHaveLength(1);
expect(front?.querySelector("[data-artifact-print]")).not.toBeNull();
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320"><rect width="480" height="320" fill="#f5f5f4"/><g transform="translate(192 112) scale(4)" fill="none" stroke="#242424" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 6 l-8.414 8.586 a2 2 0 0 0 2.828 2.828 l8.414 -8.586 a4 4 0 1 0 -5.657 -5.657 l-8.379 8.551 a6 6 0 1 0 8.485 8.485 l8.379 -8.551"/></g></svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -206,7 +206,7 @@ export function CoordinatorNeedsYou() {
</SheetContent>
</Sheet> : null}
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Needs you</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
<div role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="coordinator-decisions" role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search decisions or task IDs…" />
<NativeSelect aria-label="Filter decisions by agent" value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} className="w-auto"><option value="">All agents</option>{(agentQuery.data ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Filter decisions by project" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} className="w-auto"><option value="">All projects</option>{projects.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</NativeSelect>
@@ -1,3 +1,5 @@
import { connectionIntentsApi } from "@/api/connection-intents";
import { readOAuthWindowOutcome } from "@/lib/oauthWindowOutcome";
import { bindAccount } from "@/lib/account-scope";
import { StepHeader } from "./ConnectionSetupHeader";
import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices";
@@ -83,7 +85,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useCopyAction } from "@/lib/use-copy-action";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
@@ -833,19 +834,16 @@ function StandardConnectionSetupFlow({
const [unavailableReconnectId, setUnavailableReconnectId] = useState<string | null>(null);
const reserveOAuthPopup = useCallback(() => {
if (host !== "dialog" || oauthPopupRef.current?.closed === false) return;
oauthPopupRef.current = window.open(
"about:blank",
"paperclip-connection-oauth",
"popup,width=720,height=760,resizable=yes,scrollbars=yes",
);
}, [host]);
if (oauthPopupRef.current?.closed === false) return;
try {
oauthPopupRef.current = window.open("about:blank", "_blank", "popup,width=720,height=760,resizable=yes,scrollbars=yes");
// Task-bound setup can resume in either host and needs its existing
// origin-checked callback. Standalone flows verify saved state instead.
if (oauthPopupRef.current && !connectionIntentId) oauthPopupRef.current.opener = null;
} catch { oauthPopupRef.current = null; }
}, [connectionIntentId]);
const openAuthorization = useCallback((url: string) => {
if (host !== "dialog") {
navigateTopLevel(url);
return;
}
setAuthorizationFallbackUrl(url);
const popup = oauthPopupRef.current;
if (!popup || popup.closed) {
@@ -868,7 +866,7 @@ function StandardConnectionSetupFlow({
}, [onPhaseChange]);
useEffect(() => {
if (host !== "dialog" || oauthPhase !== "redirecting") return;
if (oauthPhase !== "redirecting") return;
const timer = window.setInterval(() => {
if (!oauthPopupRef.current?.closed) return;
setOAuthPhase("error");
@@ -888,7 +886,7 @@ function StandardConnectionSetupFlow({
try {
const target = await prepareOAuthNavigation(start, { signal: controller.signal });
if (target.kind === "reauthentication") {
const destination = host === "dialog" ? oauthPopupRef.current : window;
const destination = oauthPopupRef.current;
if (!destination || destination.closed || !start.handoff) {
throw new Error("Paperclip couldn’t preserve this sign-in while refreshing your account.");
}
@@ -899,8 +897,11 @@ function StandardConnectionSetupFlow({
setOAuthPhase("redirecting");
}
openAuthorization(target.url);
if (target.kind === "reauthentication") setAuthorizationFallbackUrl(null);
} catch (error) {
if (controller.signal.aborted) return;
oauthPopupRef.current?.close();
oauthPopupRef.current = null;
setOAuthPhase("error");
setOAuthError(describeError(error).body);
onPhaseChange?.("needs_retry");
@@ -912,13 +913,27 @@ function StandardConnectionSetupFlow({
useEffect(() => () => oauthHandoffAbortRef.current?.abort(), []);
useEffect(() => {
if (host !== "dialog" || !connectionIntentId) return;
const receiveOAuthOutcome = (event: MessageEvent) => {
if (!connectionIntentId) return;
let disposed = false;
const receiveOAuthOutcome = async (event: MessageEvent) => {
const outcome = readConnectionIntentOAuthOutcome(event, window.location.origin, connectionIntentId);
if (outcome === "connected") {
setOAuthPhase("entry");
setAuthorizationFallbackUrl(null);
onComplete?.({ resolvedByCallback: true });
if (onComplete) onComplete({ resolvedByCallback: true });
else if (host === "page") {
const account = bindAccount(queryClient);
try {
const verified = await connectionIntentsApi.setupOptions(connectionIntentId);
if (disposed || !account.isCurrentScope()) return;
if (verified.interaction.status !== "accepted") throw new Error("Authorization has not been confirmed yet. Try again.");
navigate(`/issues/${verified.interaction.issueId}`);
} catch (error) {
if (disposed || !account.isCurrentScope()) return;
setOAuthPhase("error");
setOAuthError(describeError(error).body);
}
}
return;
}
if (outcome === "declined") {
@@ -932,42 +947,43 @@ function StandardConnectionSetupFlow({
setOAuthError("Authorization did not complete. Try again when you’re ready.");
};
window.addEventListener("message", receiveOAuthOutcome);
return () => window.removeEventListener("message", receiveOAuthOutcome);
}, [connectionIntentId, host, onComplete, onOAuthDeclined]);
return () => { disposed = true; window.removeEventListener("message", receiveOAuthOutcome); };
}, [connectionIntentId, host, onComplete, onOAuthDeclined, navigate, queryClient, selectedCompanyId]);
// Standalone dialog hosts have no task interaction to receive a callback.
// Wait for this popup to return to our origin, then verify durable state via
// the API. Provider-window contents never determine the saved connection.
useEffect(() => {
if (host !== "dialog" || connectionIntentId || !dialogOAuthConnectionId) return;
if (connectionIntentId || !dialogOAuthConnectionId) return;
let cancelled = false;
let checking = false;
const timer = window.setInterval(async () => {
if (checking || cancelled) return;
const popup = oauthPopupRef.current;
if (!popup || popup.closed) {
// Native fallback links have no Window handle; leave their saved flow available.
if (!popup) return;
if (popup.closed) {
setDialogOAuthConnectionId(null);
setOAuthPhase("error");
setOAuthError("The sign-in window closed. Try again to finish connecting GitHub.");
setOAuthError("The sign-in window closed. Try again to finish connecting.");
return;
}
let returned: URL;
try { returned = new URL(popup.location.href); } catch { return; }
if (returned.origin !== window.location.origin || !returned.pathname.includes(dialogOAuthConnectionId)) return;
if (returned.searchParams.has("oauth")) {
const outcome = readOAuthWindowOutcome(popup, dialogOAuthConnectionId);
if (outcome === "waiting") return;
if (outcome !== "connected") {
setDialogOAuthConnectionId(null);
setOAuthPhase("error");
setOAuthError("Authorization did not complete. Finish setup in the sign-in window or try again.");
return;
}
if (returned.searchParams.get("success") !== "1") return;
checking = true;
try {
const connection = await toolsApi.getConnection(dialogOAuthConnectionId);
if (!cancelled && connection.status === "active") {
setDialogOAuthConnectionId(null);
popup.close();
onComplete?.({ connectionId: connection.id });
if (onComplete) onComplete({ connectionId: connection.id });
else navigate(`/apps/${connection.id}/permissions`);
}
} catch {
if (!cancelled) {
@@ -1161,18 +1177,14 @@ function StandardConnectionSetupFlow({
setConnectorEnrollmentError(target.message);
return;
}
if (host === "dialog") {
setEnrollmentAuthorizationUrl(target.url);
const popup = oauthPopupRef.current;
if (popup && !popup.closed) {
popup.location.assign(target.url);
popup.focus();
} else {
setConnectorEnrollmentError("Open authorization in a new tab to continue.");
}
return;
setEnrollmentAuthorizationUrl(target.url);
const popup = oauthPopupRef.current;
if (popup && !popup.closed) {
popup.location.assign(target.url);
popup.focus();
} else {
setConnectorEnrollmentError("Open authorization in a new tab to continue.");
}
navigateTopLevel(target.url);
}, [host, closeEnrollmentPopup]);
useEffect(() => {
if (!enrollmentAuthorizationUrl || connectorEnrollmentQuery.data?.status !== "active") return;
@@ -1387,6 +1399,8 @@ function StandardConnectionSetupFlow({
},
onSuccess: (start) => void prepareAndOpenOAuth(start),
onError: (error) => {
oauthPopupRef.current?.close();
oauthPopupRef.current = null;
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
@@ -1407,14 +1421,14 @@ function StandardConnectionSetupFlow({
setWorkspaceConsent(() => () => {
onPhaseChange?.("authorizing");
reserveOAuthPopup();
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
if (!connectionIntentId) setDialogOAuthConnectionId(connection.id);
mutateOAuthStart(connection);
});
return;
}
onPhaseChange?.("authorizing");
reserveOAuthPopup();
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
if (!connectionIntentId) setDialogOAuthConnectionId(connection.id);
mutateOAuthStart(connection);
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]);
@@ -1537,32 +1551,25 @@ function StandardConnectionSetupFlow({
// operator registers themselves. Keep the draft and ask for it in place
// rather than sending them back to the start.
if (result.auth.manualClientRequired) {
oauthPopupRef.current?.close();
oauthPopupRef.current = null;
setLinkGuidance(genericConnectGuidance("oauth_manual_client_required", null));
setLinkAuthMode("oauth");
setLinkAdvancedOpen(true);
setGenericOAuthPending(false);
return;
}
if (host === "dialog" || connectionIntentId) {
setOAuthPhase("starting");
setGenericOAuthPending(!entry);
startOAuth(result.connection);
return;
}
setOAuthPhase("starting");
setGenericOAuthPending(!entry);
const startUrl = result.auth.startUrl?.trim();
if (!startUrl) {
setOAuthPhase("starting");
setGenericOAuthPending(true);
startOAuth(result.connection);
return;
}
setGenericOAuthPending(true);
void prepareAndOpenOAuth({
authorizationUrl: startUrl,
handoff: result.auth.handoff,
});
if (startUrl && !connectionIntentId) {
setDialogOAuthConnectionId(result.connectionId);
void prepareAndOpenOAuth({ authorizationUrl: startUrl, handoff: result.auth.handoff });
} else startOAuth(result.connection);
return;
}
oauthPopupRef.current?.close();
oauthPopupRef.current = null;
setLinkGuidance(null);
setConnectResult(result);
const defaults: Record<string, boolean> = {};
@@ -1572,6 +1579,8 @@ function StandardConnectionSetupFlow({
finishMutation.mutate({ result, enabled: defaults });
},
onError: (error) => {
oauthPopupRef.current?.close();
oauthPopupRef.current = null;
const details = error instanceof ApiError && error.body && typeof error.body === "object"
? (error.body as { details?: { code?: unknown } }).details
: null;
@@ -1623,11 +1632,11 @@ function StandardConnectionSetupFlow({
});
return;
}
if (method?.auth === "oauth") {
if (method?.auth === "oauth" || (!connectEntry && !zapierSource && (linkAuthMode === "auto" || linkAuthMode === "oauth"))) {
reserveOAuthPopup();
}
mutateConnect(entryOverride);
}, [connectionMethodKey, entry, identityConnection, mutateConnect, reserveOAuthPopup]);
}, [connectionMethodKey, entry, identityConnection, linkAuthMode, zapierSource, mutateConnect, reserveOAuthPopup]);
useEffect(() => {
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
@@ -2791,7 +2800,7 @@ function StandardConnectionSetupFlow({
onBack={backToGallery}
onConnect={() => {
setLinkGuidance(null);
connectMutation.mutate(undefined);
connectApp();
}}
/>
)}
@@ -2803,7 +2812,7 @@ function StandardConnectionSetupFlow({
submitting={connectMutation.isPending}
defaults={connectionDefaults}
onBack={backToGallery}
onConnect={() => connectMutation.mutate(undefined)}
onConnect={() => connectApp()}
/>
)}
@@ -1,3 +1,5 @@
import { bindAccount } from "@/lib/account-scope";
import { connectionIntentsApi } from "@/api/connection-intents";
import { ConnectionInstructionsEditor } from "../ConnectionInstructions";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -14,7 +16,7 @@ import { useCompany } from "@/context/CompanyContext";
import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog";
import { useNavigate, useSearchParams } from "@/lib/router";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { readOAuthWindowOutcome } from "@/lib/oauthWindowOutcome";
import { queryKeys } from "@/lib/queryKeys";
import { RemoteMcpConnectionSetup } from "./RemoteMcpConnectionSetup";
import { remoteMcpProviders, type RemoteMcpProviderId } from "./providers";
@@ -90,21 +92,34 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
try { sessionStorage.setItem(accessDraftKey, JSON.stringify({ grantKind: state.grantKind, allAgents: state.allAgents, agentIds: state.agentIds })); } catch { /* Storage can be disabled. */ }
}, [connection, accessDraftKey, state.grantKind, state.allAgents, state.agentIds]);
useEffect(() => {
if (host !== "dialog" || !interactionId) return;
const receive = (event: MessageEvent) => {
if (!interactionId) return;
let disposed = false;
const receive = async (event: MessageEvent) => {
const outcome = readConnectionIntentOAuthOutcome(event, window.location.origin, interactionId);
if (!outcome) return;
if (outcome === "connected") {
// The host verifies durable acceptance; a postMessage is never proof.
onComplete?.({ resolvedByCallback: true });
if (onComplete) onComplete({ resolvedByCallback: true });
else if (host === "page") {
const account = bindAccount(queries);
try {
const verified = await connectionIntentsApi.setupOptions(interactionId);
if (disposed || !account.isCurrentScope()) return;
if (verified.interaction.status !== "accepted") throw new Error("Authorization has not been confirmed yet. Try again.");
navigate(`/issues/${verified.interaction.issueId}`);
} catch {
if (disposed || !account.isCurrentScope()) return;
setState((s) => ({ ...s, connectStatus: "oauth_failed" }));
}
}
} else {
setState((s) => ({ ...s, connectStatus: outcome === "declined" ? "cancelled" : "oauth_failed" }));
onPhaseChange?.("needs_retry");
}
};
window.addEventListener("message", receive);
return () => window.removeEventListener("message", receive);
}, [host, interactionId, onComplete, onPhaseChange]);
return () => { disposed = true; window.removeEventListener("message", receive); };
}, [host, interactionId, onComplete, onPhaseChange, navigate, queries, selectedCompanyId]);
const edit = (patch: Partial<RemoteMcpSetupState>) => setState((s) => ({ ...s, ...patch }));
const finish = async (id: string) => {
try { sessionStorage.removeItem(accessDraftKey); } catch { /* Storage can be disabled. */ }
@@ -128,6 +143,40 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
} else if (isAppAggregator(providerId)) navigate("/apps");
else navigate(`/apps/${id}/permissions`);
};
useEffect(() => {
if (state.connectStatus !== "sign_in" || !savedConnection.current) return;
const id = savedConnection.current.id;
let cancelled = false;
let checking = false;
const check = async () => {
if (checking || cancelled) return;
// Reconnect can begin with an already-active row. Wait for this attempt’s callback.
const outcome = readOAuthWindowOutcome(popup.current, id);
if (outcome === "waiting") return;
if (outcome !== "connected") {
popup.current?.close();
popup.current = null;
setState((s) => ({ ...s, connectStatus: outcome === "failed" ? "oauth_failed" : "cancelled" }));
onPhaseChange?.("needs_retry");
return;
}
if (interactionId) return; // The intent callback and host own durable acceptance.
checking = true;
try {
const saved = await toolsApi.getConnection(id);
if (!cancelled && saved.status === "active") {
cancelled = true;
popup.current?.close();
popup.current = null;
await finish(id);
}
} catch { /* Keep the saved sign-in and allow a later focus or poll to retry. */ }
finally { checking = false; }
};
const timer = window.setInterval(() => void check(), 2000);
window.addEventListener("focus", check);
return () => { cancelled = true; window.clearInterval(timer); window.removeEventListener("focus", check); };
}, [state.connectStatus, interactionId]);
const submit = async (saveDraft = false) => {
if (busy.current || !selectedCompanyId || !instructionsValid || (connection && !installs.data)) return;
try {
@@ -136,9 +185,11 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
} catch { edit({ connectStatus: "invalid_url" }); return; }
// Reserve the window while handling the click so popup blockers do not
// discard the later OAuth response. URL/token-only providers never need it.
if (!saveDraft && host === "dialog" && state.auth === "auto" && (!popup.current || popup.current.closed)) {
if (!saveDraft && state.auth === "auto" && (!popup.current || popup.current.closed)) {
try {
popup.current = window.open("about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes");
popup.current = window.open("about:blank", "_blank", "popup,width=720,height=760,resizable=yes,scrollbars=yes");
// Task-bound callbacks must work in both page and dialog hosts.
if (popup.current && !interactionId) popup.current.opener = null;
} catch { popup.current = null; }
}
busy.current = true;
@@ -190,11 +241,9 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
authorizationUrl.current = target.url;
edit({ connectStatus: "sign_in", token: "", headers: [] });
try {
if (host === "dialog") {
if (!popup.current || popup.current.closed) throw new Error("Sign-in window unavailable");
popup.current.location.assign(target.url);
popup.current.focus();
} else navigateTopLevel(target.url);
if (!popup.current || popup.current.closed) throw new Error("Sign-in window unavailable");
popup.current.location.assign(target.url);
popup.current.focus();
} catch {
// The connection and OAuth session already exist. Preserve them and
// let the native sign-in link recover a blocked browser handoff.
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAuthorizationWindow } from "./useAuthorizationWindow";
const prepare = vi.hoisted(() => vi.fn());
const save = vi.hoisted(() => vi.fn());
vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: prepare, savePendingCloudHandoff: save }));
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
function renderHook(_hook: typeof useAuthorizationWindow) {
const result = {} as { current: ReturnType<typeof useAuthorizationWindow> };
function Harness() { result.current = useAuthorizationWindow(); return result.current.fallback; }
act(() => root.render(<Harness />));
return { result };
}
function windowStub() {
return { closed: false, opener: window, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn(), sessionStorage: {} };
}
describe("connector authorization windows", () => {
it("reserves synchronously and navigates the reserved window after preparation", async () => {
const popup = windowStub();
const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
prepare.mockResolvedValue({ kind: "authorization", url: "https://provider.example/authorize" });
const { result } = renderHook(useAuthorizationWindow);
act(() => result.current.reserve());
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
expect(popup.location.assign).not.toHaveBeenCalled();
await act(() => result.current.open("https://provider.example/authorize"));
expect(popup.location.assign).toHaveBeenCalledWith("https://provider.example/authorize");
expect(popup.opener).toBeNull();
});
it("offers a real new-tab link when a popup is blocked", async () => {
vi.spyOn(window, "open").mockReturnValue(null);
prepare.mockResolvedValue({ kind: "authorization", url: "https://provider.example/authorize" });
const { result } = renderHook(useAuthorizationWindow);
act(() => result.current.reserve());
await act(() => result.current.open("https://provider.example/authorize"));
const link = container.querySelector("a");
expect(link?.textContent).toBe("Open sign-in in a new tab");
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noopener noreferrer");
});
it("closes the blank window when URL validation or handoff fails", async () => {
const popup = windowStub();
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
prepare.mockRejectedValue(new Error("Invalid authorization address"));
const { result } = renderHook(useAuthorizationWindow);
act(() => result.current.reserve());
await act(async () => { await expect(result.current.open("javascript:alert(1)")).rejects.toThrow("Invalid authorization address"); });
expect(popup.close).toHaveBeenCalled();
expect(result.current.fallback).toBeNull();
});
it("stores reauthentication state in the new window, never in a link without that state", async () => {
const popup = windowStub();
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
prepare.mockResolvedValue({ kind: "reauthentication", url: "/cloud/connections/reauth" });
const { result } = renderHook(useAuthorizationWindow);
act(() => result.current.reserve());
await act(() => result.current.open({ authorizationUrl: "", handoff: { kind: "paperclip_cloud", session: "saved-handoff-session" } }));
expect(save).toHaveBeenCalledWith("saved-handoff-session", popup.sessionStorage);
expect(result.current.fallback).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useRef, useState } from "react";
import type { ToolOAuthStartResult } from "@paperclipai/shared";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { Button } from "@/components/ui/button";
/** Reserve during the click, before any request, and keep the connector page open. */
export function useAuthorizationWindow() {
const popup = useRef<Window | null>(null);
const [fallbackUrl, setFallbackUrl] = useState<string | null>(null);
const reserve = useCallback(() => {
setFallbackUrl(null);
if (popup.current && !popup.current.closed) return;
try {
popup.current = window.open("about:blank", "_blank");
if (popup.current) popup.current.opener = null;
}
catch { popup.current = null; }
}, []);
const cancel = useCallback(() => {
popup.current?.close();
popup.current = null;
setFallbackUrl(null);
}, []);
const open = useCallback(async (start: string | Pick<ToolOAuthStartResult, "authorizationUrl" | "handoff">) => {
try {
const request = typeof start === "string" ? { authorizationUrl: start } : start;
const target = await prepareOAuthNavigation(request);
const destination = popup.current;
if (target.kind === "reauthentication" && request.handoff) {
if (!destination || destination.closed) throw new Error("Allow a sign-in window and try again to refresh your account.");
savePendingCloudHandoff(request.handoff.session, destination.sessionStorage);
}
// Reauthentication requires the session already stored in this window.
setFallbackUrl(target.kind === "authorization" ? target.url : null);
if (destination && !destination.closed) {
destination.location.assign(target.url);
destination.focus();
}
return target;
} catch (error) { cancel(); throw error; }
}, [cancel]);
const fallback = fallbackUrl ? <Button variant="outline" asChild>
<a href={fallbackUrl} target="_blank" rel="noopener noreferrer">Open sign-in in a new tab</a>
</Button> : null;
return { reserve, open, cancel, fallback, windowRef: popup };
}
+86 -20
View File
@@ -608,6 +608,8 @@
--agent-detail-tile: color-mix(in oklab, var(--foreground) 4%, var(--background));
--agent-detail-tile-hover: color-mix(in oklab, var(--foreground) 7%, var(--background));
--agent-detail-tile-chip: color-mix(in oklab, var(--foreground) 8%, transparent);
--agent-identity-chip-bg: var(--muted);
--agent-identity-chip-hover: color-mix(in oklab, var(--foreground) 14%, var(--background));
--agent-detail-tile-input: color-mix(in oklab, var(--foreground) 5%, var(--agent-detail-tile));
--agent-detail-table-head: color-mix(in oklab, var(--foreground) 3%, transparent);
--agent-detail-control-border: color-mix(in oklab, var(--foreground) 20%, transparent);
@@ -4774,7 +4776,7 @@ html:has(.design-proposal-theme) .design-navigation-group-label {
.task-filter-empty { padding: var(--task-filter-row-padding) var(--task-filter-row-padding-inline); color: var(--flyout-item-foreground); font-size: var(--flyout-type-size); line-height: var(--flyout-type-leading); }
.task-list-group-heading { margin-block: var(--design-page-toolbar-gap) var(--design-page-control-gap); }
/* Canonical collection toolbars: shared geometry, opt-in proposal styling.
/* Canonical collection toolbars: shared Tasks geometry on every surface.
Search uses ToolbarSearch; only Tasks puts its view/heading before search. */
:root {
--collection-toolbar-height: var(--design-page-control-height);
@@ -4783,7 +4785,7 @@ html:has(.design-proposal-theme) .design-navigation-group-label {
--collection-toolbar-border: var(--design-page-control-border);
--collection-toolbar-padding: var(--design-page-control-padding);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] {
[data-canonical-toolbar] {
display: flex;
flex-direction: row;
flex-wrap: wrap;
@@ -4793,12 +4795,12 @@ html:has(.design-proposal-theme) [data-canonical-toolbar] {
row-gap: var(--design-page-toolbar-gap);
min-width: 0;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] :is(.design-toolbar-controls, .design-toolbar-actions, [role="tablist"]:not([data-canonical-tabs])) {
[data-canonical-toolbar] :is(.design-toolbar-controls, .design-toolbar-actions, [role="tablist"]:not([data-canonical-tabs])) {
display: flex;
flex-wrap: wrap;
gap: var(--collection-toolbar-gap);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), [data-toolbar-control]) {
[data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), a[data-slot="button"], select, [data-toolbar-control]) {
height: var(--collection-toolbar-height);
border: var(--task-row-rule-width) solid var(--collection-toolbar-border);
border-radius: var(--collection-toolbar-radius);
@@ -4810,51 +4812,51 @@ html:has(.design-proposal-theme) [data-canonical-toolbar] :is(button:not([data-t
background: var(--design-page-control-surface);
color: var(--muted-foreground);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-size="icon"], [data-size="icon-sm"], .design-toolbar-icon) {
[data-canonical-toolbar] button:not([data-toolbar-clear]):is([data-size="icon"], [data-size="icon-sm"], [data-size="icon-xs"], .design-toolbar-icon) {
width: var(--collection-toolbar-height);
padding-inline: 0;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), [data-toolbar-control]):is(:hover, [aria-pressed="true"], [aria-selected="true"], [data-active="true"], [data-state="open"]) {
[data-canonical-toolbar] :is(button:not([data-toolbar-clear], .canonical-tab), a[data-slot="button"], select, [data-toolbar-control]):is(:hover, [aria-pressed="true"], [aria-selected="true"], [data-active="true"], [data-state="open"]) {
color: var(--foreground);
background: var(--muted-highlight);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task) {
[data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task) {
margin-inline-start: 0;
background: var(--foreground);
color: var(--background);
font-weight: var(--font-weight-semibold);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task):hover {
[data-canonical-toolbar] button:is([data-toolbar-primary], .design-new-task):hover {
background: color-mix(in oklab, var(--foreground) 90%, var(--task-list-surface));
color: var(--background);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] button > svg {
[data-canonical-toolbar] :is(button:not([data-toolbar-clear]), a[data-slot="button"]) > svg {
width: var(--design-page-icon-size);
height: var(--design-page-icon-size);
margin-inline: 0;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle {
[data-canonical-toolbar] .design-view-toggle {
height: var(--collection-toolbar-height);
overflow: visible;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button {
[data-canonical-toolbar] .design-view-toggle button {
height: calc(var(--collection-toolbar-height) - var(--task-row-rule-width) * 2);
border: 0;
border-radius: 0;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button:first-child {
[data-canonical-toolbar] .design-view-toggle button:first-child {
border-start-start-radius: var(--collection-toolbar-radius);
border-end-start-radius: var(--collection-toolbar-radius);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] .design-view-toggle button:last-child {
[data-canonical-toolbar] .design-view-toggle button:last-child {
border-start-end-radius: var(--collection-toolbar-radius);
border-end-end-radius: var(--collection-toolbar-radius);
}
html:has(.design-proposal-theme) [data-canonical-toolbar] [data-toolbar-search] {
[data-canonical-toolbar] [data-toolbar-search] {
flex-shrink: 1;
max-width: 100%;
}
html:has(.design-proposal-theme) [data-canonical-toolbar] [data-toolbar-search] input {
[data-canonical-toolbar] [data-toolbar-search] input {
background: transparent;
}
@@ -4925,7 +4927,7 @@ html:has(.design-proposal-theme) [data-canonical-toolbar] [data-toolbar-search]
box-shadow: var(--focus-ring-shadow);
}
[data-canonical-tabs] .canonical-tab:disabled { opacity: var(--canonical-tab-disabled-opacity); pointer-events: none; }
html:has(.design-proposal-theme) [data-canonical-toolbar] .collection-toolbar-tabs select {
[data-canonical-toolbar] .collection-toolbar-tabs select {
height: var(--collection-toolbar-height);
border-radius: var(--collection-toolbar-radius);
border-color: var(--collection-toolbar-border);
@@ -4970,16 +4972,16 @@ html:has(.design-proposal-theme) [data-canonical-toolbar] .collection-toolbar-ta
transform: rotate(var(--artifact-base, 0deg));
}
@media (prefers-reduced-motion: no-preference) {
.artifact-tile :is([data-artifact-rotate][data-artifact-spread], [data-artifact-front]) {
:is(.artifact-tile, .empty-state-artwork) :is([data-artifact-rotate][data-artifact-spread], [data-artifact-front]) {
transition: transform var(--artifact-hover-ease);
}
.artifact-tile:hover [data-artifact-front] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-front] {
transform: translateY(-6px);
}
.artifact-tile:hover [data-artifact-spread="left"] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-spread="left"] {
transform: rotate(calc(var(--artifact-base, 0deg) + 5deg));
}
.artifact-tile:hover [data-artifact-spread="right"] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-spread="right"] {
transform: rotate(calc(var(--artifact-base, 0deg) - 5deg));
}
}
@@ -5226,3 +5228,67 @@ html:not(.dark) .coordinator-sidebar {
.agent-config-panels + h3 {
font-weight: 600;
}
/* Large collection empty states share the artifact fan, with 20px rhythm. */
.empty-state-artwork > p { margin-block: 0; }
.empty-state-graphic { display: flex; align-items: flex-end; justify-content: center; max-width: 100%; }
[data-canonical-toolbar] input:not([type="checkbox"], [type="radio"]) {
height: var(--collection-toolbar-height);
border-radius: var(--collection-toolbar-radius);
font-size: var(--design-page-type-size);
}
[data-canonical-toolbar] .design-task-search input { height: 100%; }
[data-canonical-toolbar] .design-view-toggle { align-items: center; }
[data-canonical-toolbar] .design-view-toggle [role="tablist"] { height: 100%; padding: 0; gap: 0; background: transparent; }
[data-canonical-toolbar="collection"] { display: block; }
[data-canonical-toolbar="collection"] [data-slot="collection-toolbar-controls"],
[data-canonical-toolbar="collection"] [data-slot="collection-toolbar-actions"] { gap: var(--collection-toolbar-gap); }
@media (pointer: coarse) {
[data-canonical-toolbar] input:not([type="checkbox"], [type="radio"]) { font-size: var(--text-base); }
}
:root {
--selection-bar-bottom: calc(var(--spacing) * 12);
--selection-bar-travel: calc(var(--spacing) * 4);
--selection-bar-enter: var(--motion-duration-base);
--selection-bar-exit: var(--motion-duration-fast);
--selection-bar-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--selection-bar-ease-in: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--selection-bar-shadow: 0 8px 32px rgb(0 0 0 / 18%);
}
.agent-selection-bar {
position: fixed;
z-index: var(--z-40);
bottom: calc(var(--selection-bar-bottom) + env(safe-area-inset-bottom));
left: 50%;
width: max-content;
max-width: calc(100% - var(--spacing) * 8);
border-color: var(--border);
box-shadow: var(--selection-bar-shadow);
opacity: 0;
visibility: hidden;
transform: translate(-50%, var(--selection-bar-travel));
transition: opacity var(--selection-bar-exit) var(--selection-bar-ease-in), transform var(--selection-bar-exit) var(--selection-bar-ease-in), visibility 0s var(--selection-bar-exit);
}
.agent-selection-bar[data-state="open"] {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
transition: opacity var(--selection-bar-enter) var(--selection-bar-ease-out), transform var(--selection-bar-enter) var(--selection-bar-ease-out), visibility 0s;
}
.agent-identity-chip { background: var(--agent-identity-chip-bg); color: var(--foreground); }
/* Raised modules use the Skills chip surface to keep agent chips distinct from their tile. */
[data-agent-tile] .agent-identity-chip { --agent-identity-chip-bg: var(--agent-detail-tile-chip); }
:is(a, button).agent-identity-chip { transition: background-color var(--motion-duration-fast); }
:is(a, button).agent-identity-chip:hover:not(:disabled) { background: var(--agent-identity-chip-hover); }
:is(a, button).agent-identity-chip:focus-visible { outline: var(--sz-2px) solid var(--ring); outline-offset: var(--sz-2px); }
.inline-editor-fit { display: inline-grid; min-width: 0; max-width: 100%; }
.inline-editor-fit > * { grid-area: 1 / 1; min-width: 0; }
.inline-editor-fit > textarea { width: 0; min-width: calc(100% + var(--spacing) * 2); }
.agent-roster .agent-roster-identity { padding-inline: 0; background: transparent; border: 0; }
.agent-roster [scope="col"] { vertical-align: middle; }
[data-canonical-toolbar="agent-filters"], [data-canonical-toolbar="gateways"] { justify-content: space-between; }
[data-canonical-toolbar="audit-activity"], [data-canonical-toolbar="audit-runs"] { align-items: flex-end; }
@media (prefers-reduced-motion: reduce) {
.agent-selection-bar { transition: none; transform: translate(-50%, 0); }
}
+26
View File
@@ -0,0 +1,26 @@
/** A callback is only a signal to re-read the connection from the server. */
export function readOAuthWindowOutcome(
popup: Window | null,
connectionId: string,
): "waiting" | "closed" | "connected" | "denied" | "failed" {
if (!popup) return "waiting"; // A native fallback link has no window handle.
if (popup.closed) return "closed";
try {
const url = new URL(popup.location.href);
if (url.origin !== window.location.origin) return "waiting";
if (!url.pathname.split("/").includes(connectionId)
&& url.searchParams.get("resume") !== connectionId
&& url.searchParams.get("reconnect") !== connectionId) return "waiting";
const outcome = url.searchParams.get("oauth");
if (outcome === "denied" || outcome === "failed") return outcome;
// AppDetail consumes success=1 on mount. The exact permissions route is
// also the durable callback destination after that query flag is removed.
// Callers must still verify the saved connection before reporting success.
return url.searchParams.get("success") === "1"
|| url.pathname.endsWith(`/apps/${connectionId}/permissions`)
? "connected" : "waiting";
} catch {
// Reading location is forbidden while the window is at the provider.
return "waiting";
}
}
+6 -2
View File
@@ -137,6 +137,7 @@ import { InstructionsBundleSettings } from "./agent-detail/InstructionsBundleSet
import { AgentAllowedConnectors } from "./agent-detail/AgentAllowedConnectors";
import { AgentAuditTab } from "./AgentAuditTab";
import { AgentDirectReports } from "./agent-detail/AgentDirectReports";
import { AgentReportingManager } from "./agent-detail/AgentReportingManager";
import { AgentChip, AgentMetricTile, AgentPanel, AgentSectionHeading } from "../components/agent-detail/AgentPanels";
import { AgentHistoryDialog } from "./agent-detail/AgentHistoryDialog";
import { countSince, formatWindowCount } from "./agent-detail/overview-counts";
@@ -886,6 +887,7 @@ export function AgentDetail() {
<div className="flex min-w-0 items-center gap-2">
<InlineEditor
as="h1"
fitContent
value={agent.name}
placeholder="Agent name"
className="min-w-0 truncate text-3xl font-semibold leading-9 text-foreground"
@@ -902,9 +904,10 @@ export function AgentDetail() {
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-4 text-muted-foreground">
<InlineEditor
value={agent.title ?? ""}
fitContent
nullable
placeholder={roleLabels[agent.role] ?? agent.role}
className="text-xs leading-4 text-muted-foreground"
className="whitespace-nowrap text-xs leading-4 text-muted-foreground"
onSave={(title) => saveHeaderField({ title: title || null })}
/>
<span aria-hidden>·</span>
@@ -1495,8 +1498,9 @@ export function AgentOverview({
</span>
)}
</div>
<AgentReportingManager key={agent.id} agent={agent} />
<div className="flex flex-col gap-1.5 px-(--agent-detail-module-inset) py-1.5 text-sm">
<span className="text-muted-foreground">Direct reports</span>
<span className="text-muted-foreground">Direct reports:</span>
{directReports.length === 0 ? (
<span className="text-foreground">None</span>
) : (
+15 -19
View File
@@ -382,7 +382,7 @@ export function Agents() {
subtitleClassName="whitespace-normal break-words xl:truncate xl:whitespace-nowrap"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className="group"
className="agent-roster-identity group py-3"
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
@@ -391,7 +391,6 @@ export function Agents() {
secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")}
secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && (
<div className="flex flex-wrap items-center gap-2">
{agent.status === "paused" && <span className="sm:hidden"><AgentStatusBadge status={agent.status} /></span>}
{builtInCluster}
{showEnvironmentColumn && <span className="text-xs text-muted-foreground" title={resolveRenderedEnvironment(agent.id).title}>
{resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail}
@@ -399,26 +398,23 @@ export function Agents() {
{agent.lastHeartbeatAt && <span className="text-xs text-muted-foreground" title="Last heartbeat">{relativeTime(agent.lastHeartbeatAt)}</span>}
</div>
)}
trailing={
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
</div>
</div>
}
/>
);
};
const renderAgentStatus = (agent: Agent) => (
<div className="flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<AgentStatusBadge status={agent.status} />
</div>
);
return (
<div className="space-y-4">
<div className="flex justify-end">
@@ -476,7 +472,7 @@ export function Agents() {
{effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
visibleAgents={filtered} renderAgent={renderAgentRow} />
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus} />
)}
+5 -5
View File
@@ -539,7 +539,7 @@ describe("Agents", () => {
const row = findAgentRow(container, "Paperclip Engineer With A Much Longer Display Name");
expect(row).not.toBeNull();
expect(row?.querySelector(".sm\\:hidden")).toBeNull();
expect(row?.querySelector(".hidden.sm\\:flex")).not.toBeNull();
expect(row?.closest("tr")?.textContent).toContain("idle");
expect(row?.classList.contains("text-foreground/55")).toBe(false);
expect(row?.classList.contains("sm:text-foreground/55")).toBe(false);
@@ -910,10 +910,10 @@ describe("Agents", () => {
const checkbox = container.querySelector<HTMLButtonElement>('[aria-label="Select Alpha"]');
expect(checkbox).not.toBeNull();
await act(async () => { checkbox!.click(); });
expect(container.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 selected");
expect(container.textContent).toContain("Edit connection & model");
expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 selected");
expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("Edit connection & model");
expect(mockRouterState.navigate).not.toHaveBeenCalled();
expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Harness", "Connection", "Default model"]);
expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Status", "Harness", "Connection", "Default model"]);
});
it.each([
@@ -996,7 +996,7 @@ describe("Agents", () => {
expect(row?.classList.contains("text-foreground/55")).toBe(false);
expect(row?.classList.contains("sm:text-foreground/55")).toBe(false);
expect(row?.classList.contains("opacity-50")).toBe(false);
expect(row?.querySelector(".sm\\:hidden")?.textContent).toContain("paused");
expect(row?.closest("tr")?.textContent).toContain("paused");
});
it("keeps invalid-org-chain agents visible with a warning marker", async () => {
+27 -21
View File
@@ -27,7 +27,8 @@ import { OrgChart } from "./OrgChart";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { AgentsToolbar } from "../components/AgentsToolbar";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Bot } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertTriangle, Bot, MessagesSquare } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
@@ -389,7 +390,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
subtitleClassName="truncate"
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className="group py-3"
className="agent-roster-identity group py-3"
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
@@ -398,7 +399,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
secondaryRowClassName={cn("pl-11", !builtInCluster && !showEnvironmentColumn && !agent.lastHeartbeatAt && "sm:hidden")}
secondaryRow={(agent.status === "paused" || builtInCluster || showEnvironmentColumn || agent.lastHeartbeatAt) && (
<div className="flex flex-wrap items-center gap-2">
{agent.status === "paused" && <span className="sm:hidden"><AgentStatusBadge status={agent.status} /></span>}
{builtInCluster}
{showEnvironmentColumn && <span className="text-xs text-muted-foreground" title={resolveRenderedEnvironment(agent.id).title}>
{resolveRenderedEnvironment(agent.id).label} · {resolveRenderedEnvironment(agent.id).detail}
@@ -406,27 +406,23 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{agent.lastHeartbeatAt && <span className="text-xs text-muted-foreground" title="Last heartbeat">{relativeTime(agent.lastHeartbeatAt)}</span>}
</div>
)}
trailing={
<div className="flex items-center gap-3">
{agentChat.enabled && <Button variant="ghost" size="sm" onClick={event => { event.preventDefault(); event.stopPropagation(); navigate(`/chats/${agentRouteRef(agent)}`); }}>Chat</Button>}
<div className="hidden sm:flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="w-20 flex justify-end">
<AgentStatusBadge status={agent.status} />
</span>
</div>
</div>
}
/>
);
};
const renderAgentStatus = (agent: Agent) => (
<div className="flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentRef={agentRouteRef(agent)}
runId={liveRunByAgent.get(agent.id)!.runId}
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<AgentStatusBadge status={agent.status} />
</div>
);
return (
<div className={cn(
"flex flex-col gap-4 @container",
@@ -450,7 +446,17 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
visibleAgents={filtered} renderAgent={renderAgentRow} />
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus}
renderChat={agentChat.enabled ? agent => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`Chat with ${agent.name}`} onClick={() => navigate(`/chats/${agentRouteRef(agent)}`)}>
<MessagesSquare className="size-4 text-foreground" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>chat with this agent</TooltipContent>
</Tooltip>
) : undefined} />
)}
+4 -2
View File
@@ -10,6 +10,7 @@ import {
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ArtifactEmptyGraphic } from "@/components/artifacts/v1/ArtifactEmptyGraphic";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { QueryErrorState, useQueryView } from "../components/QueryView";
@@ -242,7 +243,7 @@ export function Artifacts() {
: viewingSelectedStack
? "No artifacts in this stack match the current filters."
: kind === "all"
? "No artifacts yet. Outputs attached to issues will appear here."
? "No artifacts yet. Outputs attached to tasks will appear here."
: "No artifacts of this type yet.";
return (
@@ -288,7 +289,8 @@ export function Artifacts() {
retrying={artifactsView.isFetching}
/>
) : items.length === 0 ? (
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage} />
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage}
graphic={<ArtifactEmptyGraphic stack={showGroupCards} kind={kind === "all" ? "file" : kind === "text" ? "code" : kind} />} />
) : v1 ? (
<>
{/* Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): a task with several artifacts is a stack
+2 -2
View File
@@ -1204,8 +1204,8 @@ export function DiscoveryGrid({
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Search + sort + actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div className="flex h-9 min-w-(--sz-12rem) flex-1 items-center gap-2 rounded-md border border-border px-2.5">
<div data-canonical-toolbar="skill-catalog" role="toolbar" aria-label="Skill catalog controls" className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<div data-toolbar-control className="design-task-search flex min-w-(--sz-12rem) flex-1 items-center gap-2 rounded-md border border-border px-2.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
value={search}
+2 -2
View File
@@ -625,7 +625,7 @@ export function Costs({
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="cost-range" role="toolbar" aria-label="Cost date range" className="flex flex-wrap items-center gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
@@ -641,7 +641,7 @@ export function Costs({
</div>
{preset === "custom" ? (
<div className="flex flex-wrap items-center gap-2 border border-border p-3">
<div data-canonical-toolbar="cost-dates" className="flex flex-wrap items-center gap-2 border border-border p-3">
<input
type="date"
value={customFrom}
+4 -1
View File
@@ -11,6 +11,7 @@ import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { ArtifactEmptyGraphic } from "@/components/artifacts/v1/ArtifactEmptyGraphic";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { QueryErrorState, useQueryView } from "../components/QueryView";
@@ -147,8 +148,10 @@ export function Projects() {
{!isLoading && projects.length === 0 && (
<EmptyState
icon={Hexagon}
graphic={<ArtifactEmptyGraphic stack />}
message="No projects yet."
action="Add Project"
action="Create project"
hideActionIcon
onAction={openNewProject}
/>
)}
+3 -3
View File
@@ -1856,7 +1856,7 @@ export function Secrets() {
<TabsContent value="secrets" className="flex flex-col gap-3">
<SecretsHowToUse />
<div className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="secrets" role="toolbar" aria-label="Secrets controls" className="flex flex-wrap items-center gap-2">
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -1882,7 +1882,7 @@ export function Secrets() {
role="group"
aria-label="View mode"
className={cn(
"inline-flex items-center rounded-md border border-border p-0.5",
"design-view-toggle inline-flex items-center rounded-md border border-border",
searching && "opacity-50",
)}
>
@@ -1923,7 +1923,7 @@ export function Secrets() {
<Folder className="mr-1 h-3.5 w-3.5" /> New folder
</Button>
) : null}
<Button onClick={openCreateSecret} size="sm">
<Button data-toolbar-primary onClick={openCreateSecret} size="sm">
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
</Button>
</div>
+1 -1
View File
@@ -2399,7 +2399,7 @@ export function TeamCatalog() {
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-5 py-3">
<div data-canonical-toolbar="teams" role="toolbar" aria-label="Teams controls" className="flex flex-wrap items-center gap-2 border-b border-border px-5 py-3">
<h1 className="text-lg font-semibold">Teams</h1>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
+2 -2
View File
@@ -396,7 +396,7 @@ export function Timeline({ embedded = false }: { embedded?: boolean } = {}) {
const summary = data ? timelineSummary(data, visibleWindow ?? dataWindow(data)) : null;
const rangeControls = (
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<label data-canonical-toolbar="timeline-range" className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
Range
<Segmented
value={rangePreset}
@@ -438,7 +438,7 @@ export function Timeline({ embedded = false }: { embedded?: boolean } = {}) {
const toolbar = (
<div className="flex flex-wrap items-start gap-3">
{summary && <TimelineSummaryStats summary={summary} />}
<div className="ml-auto flex items-center gap-1 pt-3" aria-label="Timeline zoom controls">
<div data-canonical-toolbar="timeline-zoom" role="toolbar" className="ml-auto flex items-center gap-1 pt-3" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
@@ -1,12 +1,11 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentIdentityChipContent, agentIdentityChipClass } from "@/components/agents/AgentIdentityChip";
import { Link } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
import { agentDetailHref } from "../agent-detail-navigation";
const MAX_LINES = 2;
const itemClass = "inline-flex min-w-0 max-w-full items-center gap-1.5";
const toggleClass = "text-muted-foreground transition-colors hover:text-foreground";
function lineCount(widths: number[], available: number, gap: number) {
@@ -61,15 +60,14 @@ export function AgentDirectReports({ reports }: { reports: Agent[] }) {
return (
<div className="relative">
<div ref={listRef} className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div ref={listRef} className="flex flex-wrap items-center gap-2">
{visible.map((report) => (
<Link
key={report.id}
to={agentDetailHref(agentRouteRef(report))}
className={`${itemClass} text-foreground no-underline hover:underline`}
className={`${agentIdentityChipClass} no-underline`}
>
<AgentAvatar agent={report} size={20} />
<span className="truncate">{report.name}</span>
<AgentIdentityChipContent agent={report} />
</Link>
))}
{hidden > 0 ? (
@@ -86,9 +84,8 @@ export function AgentDirectReports({ reports }: { reports: Agent[] }) {
<div aria-hidden className="pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden">
<div ref={measureRef} className="flex w-max">
{reports.map((report) => (
<span key={report.id} className="inline-flex items-center gap-1.5">
<AgentAvatar agent={report} size={20} />
<span>{report.name}</span>
<span key={report.id} className={agentIdentityChipClass}>
<AgentIdentityChipContent agent={report} />
</span>
))}
<span>+{reports.length} more</span>
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentReportingManager, invalidManagerIds } from "./AgentReportingManager";
import { queryKeys } from "@/lib/queryKeys";
const api = vi.hoisted(() => ({ list: vi.fn(), update: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: api }));
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: any) => <a href={to} {...props}>{children}</a> }));
const agent = (id: string, reportsTo: string | null = null) => ({ id, companyId: "company", name: id, urlKey: id, reportsTo, status: "idle", role: "general" }) as Agent;
const directory = [agent("Alex"), agent("Report", "Alex"), agent("Nested", "Report"), agent("Morgan")];
let root: Root | undefined;
let host: HTMLDivElement;
afterEach(async () => { if (root) await act(() => root!.unmount()); host?.remove(); vi.clearAllMocks(); });
const flush = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
async function mount(value = directory[0]) {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } });
client.setQueryData(queryKeys.agents.list("company"), directory);
api.list.mockResolvedValue(directory);
await act(() => root!.render(<QueryClientProvider client={client}><AgentReportingManager agent={value} /></QueryClientProvider>));
return client;
}
describe("Agent reporting manager", () => {
it("blocks self and descendants, even when the directory is unordered", () => {
expect(new Set(invalidManagerIds("Alex", [...directory].reverse()))).toEqual(new Set(["Alex", "Report", "Nested"]));
});
it("saves a selected manager in the current company and disables reporting loops", async () => {
api.update.mockResolvedValue({ ...directory[0], reportsTo: "Morgan" });
await mount();
expect(host.textContent).toContain("No manager");
await act(() => (host.querySelector('[aria-label="Choose manager"]') as HTMLButtonElement).click());
const popup = document.querySelector('[data-slot="popover-content"]')!;
const buttons = Array.from(popup.querySelectorAll("button"));
expect(buttons.filter(button => button.disabled)).toHaveLength(3);
await act(() => buttons.find(button => button.textContent?.startsWith("Morgan"))!.click());
await flush();
expect(api.update).toHaveBeenCalledWith("Alex", { reportsTo: "Morgan" }, "company");
});
it("keeps the cached manager and picker available after a transient directory failure", async () => {
const client = await mount({ ...directory[0], reportsTo: "Morgan" });
api.list.mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.agents.list("company") }); });
await flush();
expect(host.querySelector("a")?.textContent).toBe("Morgan");
expect((host.querySelector('[aria-label="Change manager"]') as HTMLButtonElement).disabled).toBe(false);
expect(host.querySelector('[role="alert"]')).toBeNull();
});
it("links the current manager and reports failed changes without hiding them", async () => {
api.update.mockRejectedValue(new Error("Could not save manager"));
await mount({ ...directory[0], reportsTo: "Morgan" });
expect(host.querySelector("a")?.getAttribute("href")).toBe("/agents/Morgan/overview");
await act(() => (host.querySelector('[aria-label="Change manager"]') as HTMLButtonElement).click());
const clear = Array.from(document.querySelectorAll('[data-slot="popover-content"] button')).find(button => button.textContent === "No manager") as HTMLButtonElement;
await act(() => clear.click()); await flush(); await flush();
expect(api.update).toHaveBeenCalledWith("Alex", { reportsTo: null }, "company");
expect(host.querySelector('[role="alert"]')?.textContent).toContain("Could not save manager");
expect(host.querySelector("a")?.textContent).toBe("Morgan");
});
});
@@ -0,0 +1,64 @@
import { Pencil, UserRound } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { ReportsToPicker } from "@/components/ReportsToPicker";
import { AgentIdentityChipContent, agentIdentityChipClass } from "@/components/agents/AgentIdentityChip";
import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
import { agentDetailHref } from "../agent-detail-navigation";
/** A manager cannot be the agent itself or any member of its reporting subtree. */
export function invalidManagerIds(agentId: string, agents: Agent[]): string[] {
const invalid = new Set([agentId]);
let changed = true;
while (changed) {
changed = false;
for (const agent of agents) {
if (agent.reportsTo && invalid.has(agent.reportsTo) && !invalid.has(agent.id)) {
invalid.add(agent.id);
changed = true;
}
}
}
return [...invalid];
}
export function AgentReportingManager({ agent }: { agent: Agent }) {
const client = useQueryClient();
const directory = useQuery({
queryKey: queryKeys.agents.list(agent.companyId),
queryFn: () => agentsApi.list(agent.companyId),
});
const directoryView = useQueryView(directory);
const update = useMutation({
mutationFn: (reportsTo: string | null) => agentsApi.update(agent.id, { reportsTo }, agent.companyId),
onSuccess: () => client.invalidateQueries({ queryKey: ["agents"] }),
});
const agents = directory.data ?? [];
const manager = agents.find((candidate) => candidate.id === agent.reportsTo);
const disabled = !directory.data || directoryView.kind === "error" || update.isPending;
return <div className="flex flex-col gap-1.5 px-(--agent-detail-module-inset) py-1.5 text-sm">
<span className="text-muted-foreground">Reports to:</span>
<div className="flex items-center gap-1.5">
{manager ? <Link to={agentDetailHref(agentRouteRef(manager))} className={`${agentIdentityChipClass} no-underline`}>
<AgentIdentityChipContent agent={manager} />
</Link> : null}
<ReportsToPicker
agents={agents}
value={agent.reportsTo}
onChange={(id) => update.mutate(id)}
disabled={disabled}
disabledAgentIds={invalidManagerIds(agent.id, agents)}
trigger={<button type="button" disabled={disabled} aria-label={manager ? "Change manager" : "Choose manager"}
className={`${manager ? "agent-identity-chip inline-flex size-7 shrink-0 items-center justify-center rounded-md" : agentIdentityChipClass} disabled:cursor-not-allowed disabled:opacity-50`}>
{manager ? <Pencil className="size-3.5" aria-hidden /> : <><UserRound className="size-5 text-muted-foreground" aria-hidden /><span>{agent.reportsTo ? "Manager unavailable" : "No manager"}</span></>}
</button>}
/>
</div>
{directoryView.kind === "error" ? <QueryErrorState error={directoryView.error} size="inline" action="load agents" onRetry={directoryView.retry} retrying={directoryView.isFetching} /> : null}
{update.error ? <QueryErrorState error={update.error} size="inline" action="change manager" /> : null}
</div>;
}
+1 -1
View File
@@ -420,7 +420,7 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
unsaved={hasUnsavedChanges}
error={syncSkills.isError && hasUnsavedChanges} // query-error-ok: syncSkills is a save mutation result, not a query read
/>
<div className="ml-auto flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center">
<div data-canonical-toolbar="agent-skills" role="toolbar" aria-label="Agent skills controls" className="ml-auto flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center">
<div className="relative w-full sm:w-auto">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
+8 -4
View File
@@ -49,6 +49,7 @@ const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "permissions
const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() }));
const navigateComponentMock = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const popupAssignMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/ai-connections/AiConnectionPoolConnector", () => ({
AiConnectionPoolConnector: () => <div>Connection pool controls</div>,
@@ -357,6 +358,9 @@ describe("AppDetail", () => {
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
sessionStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
@@ -1560,7 +1564,7 @@ describe("AppDetail", () => {
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
expect(popupAssignMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("reconnects an OAuth warning through the connection's existing personal identity", async () => {
@@ -1582,7 +1586,7 @@ describe("AppDetail", () => {
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("conn-1", { asCurrentUser: true });
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://example.test/oauth");
expect(popupAssignMock).toHaveBeenCalledWith("https://example.test/oauth");
});
it("does not offer a personal reconnect to someone other than its fixed owner", async () => {
@@ -1737,7 +1741,7 @@ describe("AppDetail", () => {
subjectUserId: "user-1",
returnTo: "/apps/conn-1/permissions",
});
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
expect(popupAssignMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
});
it("keeps personal managed authorization in the tenant until the provider is ready", async () => {
@@ -1763,7 +1767,7 @@ describe("AppDetail", () => {
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=personal",
);
});
+16 -19
View File
@@ -36,8 +36,7 @@ import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { buildCompanyUserProfileMap } from "@/lib/company-members";
import { installStateFrom, type InstallState } from "@/lib/tool-installs";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -360,15 +359,12 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
}),
});
const authorization = useAuthorizationWindow();
const startOAuth = useMutation({
mutationFn: (input?: { asAgentId?: string }) => toolsApi.startOAuth(connectionId, input),
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
if (target.kind === "reauthentication" && start.handoff) {
savePendingCloudHandoff(start.handoff.session);
}
navigateTopLevel(target.url);
await authorization.open(start);
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
@@ -377,12 +373,14 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
});
}
},
onError: (error) =>
onError: (error) => {
authorization.cancel();
pushToast({
title: "Couldn't start sign-in",
body: describeError(error).body,
tone: "error",
}),
});
},
});
const invalidateGrants = () => {
@@ -406,11 +404,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
},
onSuccess: async ({ url, handoff }) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl: url, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
navigateTopLevel(target.url);
await authorization.open({ authorizationUrl: url, handoff });
} catch (error) {
pushToast({
title: "Couldn't start sign-in",
@@ -419,12 +413,14 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
});
}
},
onError: (error) =>
onError: (error) => {
authorization.cancel();
pushToast({
title: "Couldn't start sign-in",
body: describeError(error).body,
tone: "error",
}),
});
},
});
// A denied or conflicting audience save keeps the dialog open with the
@@ -593,6 +589,7 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
return (
<div className="max-w-4xl space-y-10 pb-12">
{authorization.fallback}
<AppDetailHeader
appName={appName}
connection={connection}
@@ -698,9 +695,9 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
setAudienceOpenGrantId(null);
setAudienceError(null);
}}
onConnectAsMe={() => onReconnect ? onReconnect(connection) : startPersonalAuth.mutate()}
onConnectOrganization={() => onReconnect ? onReconnect(connection) : startOAuth.mutate()}
onConnectAgent={(agentId) => startOAuth.mutate({ asAgentId: agentId })}
onConnectAsMe={() => { if (onReconnect) onReconnect(connection); else { authorization.reserve(); startPersonalAuth.mutate(); } }}
onConnectOrganization={() => { if (onReconnect) onReconnect(connection); else { authorization.reserve(); startOAuth.mutate(); } }}
onConnectAgent={(agentId) => { authorization.reserve(); startOAuth.mutate({ asAgentId: agentId }); }}
onRefreshAccess={() => refreshGitHubAccess.mutate()}
refreshAccessPending={refreshGitHubAccess.isPending}
onReplaceAudience={(grant, memberUserIds) =>
+103 -24
View File
@@ -15,6 +15,9 @@ import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" }));
const intentSetupMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: { setupOptions: intentSetupMock } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
@@ -38,6 +41,7 @@ const mockNavigate = vi.hoisted(() => vi.fn());
const openAggregatorTaskMock = vi.hoisted(() => vi.fn());
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: openAggregatorTaskMock }) }));
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const popupAssignMock = vi.hoisted(() => vi.fn());
const mockSearch = vi.hoisted(() => ({ value: "" }));
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
const mockCompany = vi.hoisted(() => ({
@@ -352,6 +356,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
beforeEach(() => {
vi.resetAllMocks();
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
experimentalMock.mockResolvedValue({ enableChatConnectors: false });
window.sessionStorage.clear();
mockCompany.value = {
@@ -568,20 +575,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(popup).not.toHaveBeenCalled();
});
it.each(["arcade", "composio", "executor"])("inline aggregator %s binds OAuth to the task and retries the same draft", async (provider) => {
it.each([["page", "composio"], ["dialog", "arcade"], ["dialog", "composio"], ["dialog", "executor"]] as const)("%s aggregator setup preserves task callbacks and retries the same draft", async (host, provider) => {
const onComplete = vi.fn();
const onPhaseChange = vi.fn();
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
const popup = { opener: window as Window | null, closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const connection = { id: "conn-inline-oauth", status: "draft", credentialPolicy: "per_user", authKind: "oauth" };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } });
const root = await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} onPhaseChange={onPhaseChange} />);
const root = await render(undefined, false, <ConnectionSetupFlow host={host} serviceSlug={provider} requestedAgentId="agent-1" interactionId="intent-inline" onComplete={onComplete} onPhaseChange={onPhaseChange} />);
const connectLabel = `Connect ${provider[0].toUpperCase()}${provider.slice(1)}`;
if (provider === "composio") await act(async () => buttonByText("Reuse an existing session")!.click());
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://provider.example/mcp"));
await act(async () => buttonByText(connectLabel)!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" }));
expect(popup.location.assign).toHaveBeenCalled();
expect(popup.opener).toBe(window);
popup.closed = true;
expect(container.textContent).toContain("Paperclip is waiting for confirmation");
expect(container.querySelector('a[target="_blank"]')?.getAttribute("href")).toBe("https://mcp.notion.com/authorize?state=resumed");
@@ -632,11 +640,30 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(onComplete).not.toHaveBeenCalled();
});
it.each(["closed", "denied", "failed"])("makes standalone remote MCP sign-in retryable when %s", async (outcome) => {
mockSearch.value = "source=composio";
const popup = { opener: window as Window | null, closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const connection = { id: "conn-provider", status: "draft", credentialPolicy: "shared" };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } });
await render();
await act(async () => buttonByText("Connect Composio")!.click());
await vi.waitFor(() => expect(popup.location.assign).toHaveBeenCalled());
await flushReact();
expect(popup.opener).toBeNull();
if (outcome === "closed") popup.closed = true;
else popup.location.href = `${window.location.origin}/CO/apps/connect?resume=conn-provider&oauth=${outcome}`;
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(buttonByText("Try again")).toBeTruthy();
expect(mockNavigate).not.toHaveBeenCalled();
expect(popup.close).toHaveBeenCalled();
});
it.each(["arcade", "composio", "executor"])("keeps a native sign-in link when direct %s navigation is blocked", async (provider) => {
mockSearch.value = `source=${provider}`;
const connection = { id: "conn-provider", status: "draft", credentialPolicy: "shared" };
connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } });
navigateTopLevelMock.mockImplementationOnce(() => { throw new Error("Browser refused navigation"); });
popupAssignMock.mockImplementationOnce(() => { throw new Error("Browser refused navigation"); });
await render();
if (provider !== "composio") {
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[id$="-url"]')!, "https://provider.example/mcp"));
@@ -651,7 +678,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
const click = new MouseEvent("click", { bubbles: true, cancelable: true });
await act(async () => link.dispatchEvent(click));
expect(click.defaultPrevented).toBe(false);
expect(navigateTopLevelMock).toHaveBeenCalledTimes(1);
expect(popupAssignMock).toHaveBeenCalledTimes(1);
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(startOAuthMock).toHaveBeenCalledTimes(1);
expect(finishAppMock).not.toHaveBeenCalled();
@@ -877,7 +904,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => buttonByText("Try again")!.click());
await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(draft.id, { asCurrentUser: false }));
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: draft.id }));
expect(navigateTopLevelMock).toHaveBeenCalled();
expect(popupAssignMock).toHaveBeenCalled();
});
it("explains a declined Composio OAuth return without discarding the draft", async () => {
@@ -1545,7 +1572,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
galleryKey: "postman",
connectionMethodKey: "mcp-oauth-full",
}));
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://oauth.pstmn.io/authorize?state=opaque",
);
});
@@ -1622,7 +1649,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
"Paperclip",
"/apps/connect?source=gmail&stage=setup",
);
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
);
});
@@ -1689,6 +1716,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("binds OAuth to the task even when setup resumes in the page host", async () => {
const popup = { opener: window as Window | null, closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
mockSearch.value = "source=gmail&stage=setup&intent=intent-1";
listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] });
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "shared" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
@@ -1696,9 +1725,30 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => buttonByText("Continue to sign in")?.click());
await flushReact();
expect(startOAuthMock).toHaveBeenCalledWith("gmail-1", { asCurrentUser: false, interactionId: "intent-1" });
expect(popup.opener).toBe(window);
expect(navigateTopLevelMock).not.toHaveBeenCalledWith("https://example.test/unbound");
});
it.each(["gmail", "composio"])("returns %s page setup to its task only after verifying callback acceptance", async (provider) => {
mockSearch.value = `source=${provider}&stage=setup&intent=intent-1`;
listGalleryMock.mockResolvedValue({ apps: [{ ...GMAIL, ownershipAvailability: { ...GMAIL.ownershipAvailability, platform_shared: true } }] });
await render();
const message = () => window.dispatchEvent(new MessageEvent("message", {
origin: window.location.origin,
data: { type: "paperclip.connection-intent.oauth", interactionId: "intent-1", outcome: "connected" },
}));
intentSetupMock.mockResolvedValue({ interaction: { status: "pending", issueId: "task-1" } });
await act(async () => { message(); });
expect(intentSetupMock).toHaveBeenCalledWith("intent-1");
expect(mockNavigate).not.toHaveBeenCalledWith("/issues/task-1");
intentSetupMock.mockRejectedValueOnce(new Error("Request failed"));
await act(async () => { message(); });
expect(mockNavigate).not.toHaveBeenCalledWith("/issues/task-1");
intentSetupMock.mockResolvedValue({ interaction: { status: "accepted", issueId: "task-1" } });
await act(async () => { message(); });
expect(mockNavigate).toHaveBeenCalledWith("/issues/task-1");
});
it.each(["2020-01-01T00:00:00.000Z", "2099-01-01T00:00:00.000Z"])(
"revalidates a cached pending enrollment before continuing (expiry %s)",
async (expiresAt) => {
@@ -1726,7 +1776,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith(
"company-1", "Paperclip", "/apps/connect?source=github&stage=setup",
);
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://my-staging.paperclip.app/connections/enroll?id=enroll-test",
);
expect(navigateTopLevelMock).not.toHaveBeenCalledWith(
@@ -2250,7 +2300,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true });
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://api-eu.braintrust.dev/oauth/authorize");
expect(popupAssignMock).toHaveBeenCalledWith("https://api-eu.braintrust.dev/oauth/authorize");
});
it("connects Superagent with only an organization API key", async () => {
@@ -2774,7 +2824,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
"about:blank",
"paperclip-connection-oauth",
"_blank",
"popup,width=720,height=760,resizable=yes,scrollbars=yes",
);
expect(openSpy.mock.invocationCallOrder[0]).toBeLessThan(connectAppMock.mock.invocationCallOrder[0]!);
@@ -2804,7 +2854,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => dialogRoot.unmount());
});
it("returns a standalone GitHub popup to its host only after verifying the saved connection", async () => {
it.each(["page", "dialog"] as const)("returns a standalone GitHub popup to its %s host only after verifying the saved connection", async (host) => {
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
connectAppMock.mockResolvedValue({
connectionId: "conn-github", application: { id: "app-github", name: "GitHub" },
@@ -2812,11 +2862,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {},
auth: { kind: "oauth" },
});
const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
const popup = { opener: window as Window | null, closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const onComplete = vi.fn();
getConnectionMock.mockResolvedValue({ id: "conn-github", status: "active" });
const dialogRoot = await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onComplete={onComplete} />);
const dialogRoot = await render(undefined, false, <ConnectionSetupFlow host={host} serviceSlug="github" forceNewConnection onComplete={onComplete} />);
await passAccessStep();
await act(async () => buttonByText("Continue to GitHub")!.click());
await flushReact();
@@ -2824,7 +2874,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock, container.textContent ?? "").toHaveBeenCalled();
expect(startOAuthMock, container.textContent ?? "").toHaveBeenCalledWith("conn-github", { asCurrentUser: true });
expect(onComplete).not.toHaveBeenCalled();
popup.location.href = `${window.location.origin}/CO/apps/conn-github/permissions?success=1`;
expect(popup.opener).toBeNull();
// AppDetail removes the success flag as soon as its toast is shown.
popup.location.href = `${window.location.origin}/CO/apps/conn-github/permissions`;
await act(async () => {
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "conn-github" }), { timeout: 2500 });
});
@@ -2911,7 +2963,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
configValues: undefined,
applicationId: undefined,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=opaque",
);
});
@@ -2980,7 +3032,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
asCurrentUser: true,
interactionId,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=existing",
);
});
@@ -3016,7 +3068,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
"22222222-2222-4222-8222-222222222222",
{ asCurrentUser: true },
);
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=resumed",
);
});
@@ -3564,7 +3616,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
configValues: undefined,
applicationId: "app-notion",
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=new",
);
});
@@ -3674,7 +3726,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
asCurrentUser: true,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=resumed",
);
});
@@ -3730,11 +3782,36 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(startOAuthMock).toHaveBeenLastCalledWith("conn-prepared", {
asCurrentUser: true,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=retry",
);
});
it("reserves a new sign-in window synchronously when retry must recreate the draft", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockRejectedValueOnce(new Error("Create failed")).mockResolvedValueOnce({
connectionId: "conn-retried", application: { id: "app-notion", name: "Notion" },
connection: { id: "conn-retried", credentialPolicy: "shared", status: "draft" },
actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {},
auth: { kind: "oauth", startUrl: "https://mcp.notion.com/authorize?state=recreated" },
});
await render();
await passAccessStep();
await submitCuratedOAuthSetup();
expect(container.textContent).toContain("Create failed");
const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
const open = vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
open.mockClear();
let release!: (value: { connections: never[] }) => void;
listConnectionsMock.mockImplementationOnce(() => new Promise(resolve => { release = resolve; }));
await act(async () => buttonByText("Try again")!.click());
expect(open).toHaveBeenCalledOnce();
expect(connectAppMock).toHaveBeenCalledTimes(1);
await act(async () => release({ connections: [] }));
await vi.waitFor(() => expect(popup.location.assign).toHaveBeenCalledWith("https://mcp.notion.com/authorize?state=recreated"));
});
it("recovers a response-lost Notion draft before retrying creation", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
@@ -3780,7 +3857,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(startOAuthMock).toHaveBeenCalledWith("conn-response-lost", {
asCurrentUser: true,
});
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=recovered",
);
});
@@ -4322,6 +4399,8 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
let mountedRoot: Root | null;
beforeEach(() => {
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
mockSearch.value = "";
mockParams.appKey = undefined;
container = document.createElement("div");
@@ -4568,7 +4647,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
});
await flushReact();
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://auth.example.test/authorize?state=abc");
expect(popupAssignMock).toHaveBeenCalledWith("https://auth.example.test/authorize?state=abc");
// Residual risk of a real-but-hostile authorization page: name the host the
// operator is being handed to (PAP-17099).
expect(container.textContent).toContain("auth.example.test");
@@ -4637,7 +4716,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=managed",
);
});
+12 -8
View File
@@ -1,3 +1,4 @@
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -164,20 +165,21 @@ export function Connections() {
queryKey: ["cloud-connector", "enrollment"],
queryFn: () => toolsApi.getCloudConnectorEnrollment(),
});
const authorization = useAuthorizationWindow();
const galleryView = useQueryView(galleryQuery);
const applicationsView = useQueryView(applicationsQuery);
const connectionsView = useQueryView(connectionsQuery);
const connectorEnrollmentView = useQueryView(connectorEnrollmentQuery);
const startConnectorEnrollment = useMutation({
mutationFn: () => toolsApi.startCloudConnectorEnrollment(selectedCompanyId!, selectedCompany?.name),
onSuccess: (status) => {
if (status.verificationUrl) window.location.assign(status.verificationUrl);
onSuccess: async (status) => {
if (status.verificationUrl) await authorization.open(status.verificationUrl);
else authorization.cancel();
},
onError: (error) => {
authorization.cancel();
pushToast({ title: "Couldn’t reach Paperclip Cloud", body: describeError(error).body, tone: "error" });
},
onError: (error) => pushToast({
title: "Couldn’t reach Paperclip Cloud",
body: describeError(error).body,
tone: "error",
}),
});
const deleteConnection = useMutation({
@@ -318,14 +320,16 @@ export function Connections() {
return (
<div className="max-w-5xl space-y-5">
{authorization.fallback}
{!(connectorEnrollmentQuery.isLoading || connectorEnrollmentView.kind === "reconnecting") ? (
<CloudConnectorEnrollmentBanner
status={connectorEnrollmentQuery.data}
unavailable={connectorEnrollmentView.kind === "error"}
busy={startConnectorEnrollment.isPending}
onEnable={() => {
authorization.reserve();
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) window.location.assign(verificationUrl);
if (verificationUrl) void authorization.open(verificationUrl).catch(error => pushToast({ title: "Couldn’t open sign-in", body: error.message, tone: "error" }));
else startConnectorEnrollment.mutate();
}}
/>
+9 -10
View File
@@ -19,8 +19,7 @@ import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useToast } from "@/context/ToastContext";
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { cn } from "@/lib/utils";
import { Link } from "@/lib/router";
import type { AppDetailSectionProps } from "./types";
@@ -161,6 +160,7 @@ export function ReconnectCard({
reconnectUnavailableMessage?: string;
}) {
const { pushToast } = useToast();
const authorization = useAuthorizationWindow();
const reconnectOAuth = useMutation({
// Reconnect is not a new identity choice. Personal-only connections must
// put the replacement token back on the signed-in user's existing grant;
@@ -170,11 +170,7 @@ export function ReconnectCard({
: toolsApi.startOAuth(connection.id),
onSuccess: async (start) => {
try {
const target = await prepareOAuthNavigation(start);
if (target.kind === "reauthentication" && start.handoff) {
savePendingCloudHandoff(start.handoff.session);
}
navigateTopLevel(target.url);
await authorization.open(start);
} catch (error) {
pushToast({
title: "Couldn’t start sign-in",
@@ -183,12 +179,14 @@ export function ReconnectCard({
});
}
},
onError: (error) =>
onError: (error) => {
authorization.cancel();
pushToast({
title: "Couldn’t start sign-in",
body: describeError(error).body,
tone: "error",
}),
});
},
});
const verifyVercel = useMutation({
mutationFn: () => toolsApi.checkConnectionHealth(connection.id),
@@ -212,6 +210,7 @@ export function ReconnectCard({
return (
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3">
{authorization.fallback}
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}
@@ -259,7 +258,7 @@ export function ReconnectCard({
type="button"
size="sm"
disabled={reconnectOAuth.isPending}
onClick={() => reconnectOAuth.mutate()}
onClick={() => { authorization.reserve(); reconnectOAuth.mutate(); }}
>
{reconnectOAuth.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{reconnectOAuth.isPending ? "Opening sign-in…" : "Reconnect"}
+9 -3
View File
@@ -1,3 +1,4 @@
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
@@ -215,12 +216,14 @@ export function GitHubChatSetup() {
bot?.setup?.github?.ownerLogin,
bot?.setup?.github?.appName,
]);
const authorization = useAuthorizationWindow();
async function run(action: () => Promise<void>) {
setBusy(true);
setError("");
try {
await action();
} catch (error) {
authorization.cancel();
setError(
describeError(error).body,
);
@@ -294,6 +297,7 @@ export function GitHubChatSetup() {
if (connected) return null;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 px-4 py-6 sm:px-6">
{authorization.fallback}
{!identityOnly && (
<SetupWizardNavigation
labels={reconnect ? ["Reconnect GitHub"] : ["Choose agent", "Connect GitHub"]}
@@ -507,8 +511,9 @@ export function GitHubChatSetup() {
await refresh();
}
} else {
authorization.reserve();
const result = await githubChatApi.startIdentity(bot.id);
window.location.assign(result.authorizationUrl);
await authorization.open(result.authorizationUrl);
}
},
existingIdentityMethod && !legacyAccount,
@@ -524,7 +529,7 @@ export function GitHubChatSetup() {
{state.installationUrl && (
<a
className="inline-flex items-center gap-1 text-sm underline"
href={state.installationUrl}
href={state.installationUrl} target="_blank" rel="noopener noreferrer"
>
Continue installation on GitHub
<ExternalLink className="size-3" />
@@ -539,6 +544,7 @@ export function GitHubChatSetup() {
this instance.
</p>
{footer("Connect Paperclip Cloud", async () => {
authorization.reserve();
const result = await toolsApi.startCloudConnectorEnrollment(
selectedCompanyId!,
undefined,
@@ -546,7 +552,7 @@ export function GitHubChatSetup() {
);
if (!result.verificationUrl)
throw new Error("Cloud enrollment could not be started");
window.location.assign(result.verificationUrl);
await authorization.open(result.verificationUrl);
})}
</>
) : state?.state === "recovery" ? (
+10 -4
View File
@@ -1,3 +1,4 @@
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
@@ -232,6 +233,7 @@ export function SlackSearchAccess({
queryKey: ["slack-search", companyId, endpointId],
queryFn: () => slackToolsApi.search(companyId, endpointId),
});
const authorization = useAuthorizationWindow();
const view = useQueryView(query);
if (view.kind === "error")
return (
@@ -248,12 +250,16 @@ export function SlackSearchAccess({
Loading search access…
</p>
);
return (
return (<>
{authorization.fallback}
<SlackSearchView
status={query.data}
onConnect={async () => {
const result = await slackToolsApi.connect(companyId, endpointId);
window.location.assign(result.url);
authorization.reserve();
try {
const result = await slackToolsApi.connect(companyId, endpointId);
await authorization.open(result.url);
} catch (error) { authorization.cancel(); throw error; }
}}
onDisconnect={async () => {
await slackToolsApi.disconnect(companyId, endpointId);
@@ -264,5 +270,5 @@ export function SlackSearchAccess({
await query.refetch();
}}
/>
);
</>);
}
@@ -26,7 +26,7 @@ beforeEach(() => {
api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" });
navigation.mockImplementation(() => new Promise(() => {}));
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); });
async function render(overrides = {}) {
await act(async () => { root.render(<StrictMode><QueryClientProvider client={client}><ManagedSlackSetup {...props()} {...overrides} /></QueryClientProvider></StrictMode>); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
@@ -127,3 +127,37 @@ it("does not silently retry an ambiguous request after refresh of setup options"
await render(); await render();
expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1);
});
it.each(["popup", "fallback"])("continues after manager authorization returns through the %s", async (mode) => {
const popup = { closed: false, opener: window, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
popup.location.assign.mockImplementation((url: string) => { popup.location.href = url; });
vi.spyOn(window, "open").mockReturnValue(mode === "popup" ? popup as unknown as Window : null);
navigation.mockResolvedValue({ kind: "authorization", url: "https://slack.com/oauth" });
api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] });
const onSaved = vi.fn();
await render({ onSaved });
expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1);
if (mode === "popup") {
// Returning focus while Slack is still open must not interrupt sign-in.
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(api.get).not.toHaveBeenCalled();
expect(popup.close).not.toHaveBeenCalled();
popup.location.href = `${window.location.origin}/UIP/apps/chat/connect?provider=slack&resume=endpoint`;
}
if (mode === "fallback") {
await act(async () => { window.dispatchEvent(new Event("focus")); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
expect(container.querySelector('a[target="_blank"]')?.textContent).toBe("Open sign-in in a new tab");
expect(api.provisionManagedSlack).not.toHaveBeenCalled();
}
api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] });
await act(async () => { window.dispatchEvent(new Event("focus")); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
expect(onSaved).toHaveBeenCalledWith(endpoint);
expect(api.provisionManagedSlack).not.toHaveBeenCalled();
const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue")!;
await act(async () => button.click());
expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1);
expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" }));
});
@@ -1,18 +1,20 @@
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { useQueryView } from "@/components/QueryView";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
import { bindAccount } from "@/lib/account-scope";
import { chatEndpointsApi } from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SetupWizardFooter } from "@/components/SetupWizard";
import type { OwnSlackAppSetup } from "./OwnSlackAppSetup";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { sanitizedSetupErrorMessage } from "../chat-setup-error";
export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps<typeof OwnSlackAppSetup> & { autoStart?: boolean; reconnect?: boolean }) {
const client = useQueryClient();
const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId) });
const choicesView = useQueryView(choices);
const [selectedGrant, setSelectedGrant] = useState<string | null>(null);
@@ -30,24 +32,69 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on
const reconnectWorkspace = needsAuthorization && (started || Boolean(error) || endpoint.setup?.slackManagerError === "slack_manager_reauthorize");
const uncertain = registration?.status === "uncertain";
const creating = registration?.status === "creating";
const authorizationWindow = useAuthorizationWindow();
useEffect(() => {
if (!authorizationRequested || busy) return;
let disposed = false;
let refreshing = false;
const account = bindAccount(client);
const refresh = async (returnedFromProvider = false) => {
if (refreshing || !account.isCurrentScope()) return;
refreshing = true;
try {
const [options, saved] = await Promise.all([choices.refetch(), chatEndpointsApi.get(endpoint.id)]);
if (disposed || !account.isCurrentScope()) return;
if (options.isError) throw options.error;
onSaved(saved);
const hasAuthorizedWorkspace = Boolean(options.data?.workspaces.length)
&& !saved.setup?.slackManagerError
&& saved.setup?.slackRegistration?.errorCode !== "slack_manager_reauthorize";
// A focus event alone does not mean a blocked sign-in was completed.
if (!returnedFromProvider && !hasAuthorizedWorkspace) return;
setAuthorizationRequested(false);
setError(null);
authorizationWindow.cancel();
} catch (failure) {
if (!disposed && account.isCurrentScope()) setError(sanitizedSetupErrorMessage(failure, {}));
} finally { refreshing = false; }
};
const observeReturn = () => {
const popup = authorizationWindow.windowRef.current;
if (!popup) return;
if (popup.closed) { void refresh(); return; }
try {
const url = new URL(popup.location.href);
if (url.origin === window.location.origin && url.pathname.endsWith("/apps/chat/connect")
&& url.searchParams.get("resume") === endpoint.id) void refresh(true);
} catch { /* The sign-in window is still at the provider. */ }
};
// Focus also covers the native fallback link, which has no window handle.
const onFocus = () => {
if (authorizationWindow.windowRef.current) observeReturn();
else void refresh();
};
const timer = window.setInterval(observeReturn, 1_500);
window.addEventListener("focus", onFocus);
return () => { disposed = true; window.clearInterval(timer); window.removeEventListener("focus", onFocus); };
}, [authorizationRequested, busy, client, endpoint.id, choices.refetch, onSaved, authorizationWindow.cancel, authorizationWindow.windowRef]);
async function run(action: () => Promise<void>) {
if (inFlight.current) return;
inFlight.current = true; setBusy(true); onBusy(true); setError(null);
try { await action(); }
catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); }
catch (failure) { authorizationWindow.cancel(); setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); }
finally { inFlight.current = false; setBusy(false); onBusy(false); }
}
async function authorize() {
authorizationWindow.reserve();
setAuthorizationRequested(true);
const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id);
const target = await prepareOAuthNavigation(authorization);
if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session);
window.location.assign(target.url);
await authorizationWindow.open(authorization);
}
async function connect() {
if (needsAuthorization || !grantId) {
await authorize(); return;
}
authorizationWindow.reserve();
if (!started) await saveDetails();
if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID();
const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) };
@@ -55,7 +102,8 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on
const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input);
await choices.refetch();
onSaved(await chatEndpointsApi.get(endpoint.id));
if (result.authorization) window.location.assign(result.authorization.authorizationUrl);
if (result.authorization) await authorizationWindow.open(result.authorization);
else authorizationWindow.cancel();
}
const autoStarted = useRef(false);
useEffect(() => {
@@ -73,6 +121,7 @@ export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, on
: slackRegistrationErrorMessage(code);
const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? ((choicesView.kind === "error") ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null);
return <div className="space-y-5">
{authorizationWindow.fallback}
{message && <p role="alert" className="text-sm text-destructive">{message}</p>}
{busy || choices.isPending || creating ? <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}</p>
: !choices.data?.managedAvailable ? <p className="text-sm">Managed Slack setup is unavailable. Try again later.</p>
@@ -1,3 +1,4 @@
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
@@ -52,7 +53,7 @@ export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSav
onBusy(true);
setError(null);
try { await action(); }
catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); }
catch (failure) { authorization.cancel(); setError(sanitizedSetupErrorMessage(failure, { configurationToken })); }
finally {
setConfigurationToken("");
setBusy(false);
@@ -119,13 +120,15 @@ export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSav
}
}
const authorization = useAuthorizationWindow();
async function authorize() {
authorization.reserve();
const result = await chatEndpointsApi.installSlackApp(endpoint.id);
// Current-tab navigation avoids popup blockers. The callback resumes this saved draft.
window.location.assign(result.authorizationUrl);
await authorization.open(result.authorizationUrl);
}
return <div className="space-y-5">
{authorization.fallback}
{error || registration?.errorCode ? <p role="alert" className="text-sm text-destructive">
{error ?? slackRegistrationErrorMessage(registration!.errorCode!)}
</p> : null}
+2 -2
View File
@@ -159,7 +159,7 @@ export function GatewaysList() {
<EmptyGateways onCreate={() => setCreating(true)} />
) : (
<div className="space-y-4 pt-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<div data-canonical-toolbar="gateways" role="toolbar" aria-label="Gateway controls" className="flex flex-wrap items-center justify-between gap-3">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -170,7 +170,7 @@ export function GatewaysList() {
aria-label="Search gateways"
/>
</div>
<Button onClick={() => setCreating(true)}>
<Button data-toolbar-primary className="ml-auto" onClick={() => setCreating(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New gateway
</Button>
+1 -1
View File
@@ -119,7 +119,7 @@ export function AgentCommentary({ companyId }: { companyId: string }) {
return (
<section className="flex flex-col gap-5" aria-label="Commentary">
<div className="flex flex-col gap-1"><h2 className="text-lg font-semibold">Commentary</h2><p className="text-sm text-muted-foreground">Suggestions and complaints from your agents.</p></div>
<div className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="audit-commentary" role="toolbar" aria-label="Commentary filters" className="flex flex-wrap items-center gap-2">
<div className="relative min-w-48 flex-1 sm:max-w-sm"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input aria-label="Search commentary" placeholder="Search messages…" maxLength={200} value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" /></div>
<Select value={agentId} onValueChange={(value) => updateFilters({ agentId: value })}><SelectTrigger aria-label="Filter by agent" className="min-w-40"><SelectValue placeholder="All agents" /></SelectTrigger><SelectContent><SelectItem value={ALL}>All agents</SelectItem>{agents.data?.map((agent) => <SelectItem key={agent.id} value={agent.id}><AgentIdentity agent={agent} size="xs" /></SelectItem>)}</SelectContent></Select>
<Select value={kind ?? ALL} onValueChange={(value) => updateFilters({ kind: value })}><SelectTrigger aria-label="Filter by message type" className="min-w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value={ALL}>All types</SelectItem><SelectItem value="suggestion">Suggestions</SelectItem><SelectItem value="complaint">Complaints</SelectItem></SelectContent></Select>
+3 -3
View File
@@ -574,8 +574,8 @@ export function AuditFeed({
) : null}
{showModeToggle ? (
<Tabs value={resolvedMode} onValueChange={(value) => onModeChange?.(value as AuditFeedMode)}>
<TabsList aria-label="Activity scope">
<Tabs data-canonical-toolbar="audit-scope" value={resolvedMode} onValueChange={(value) => onModeChange?.(value as AuditFeedMode)}>
<TabsList className="design-view-toggle" aria-label="Activity scope">
<TabsTrigger value="all">Activity</TabsTrigger>
<TabsTrigger
value="agents"
@@ -600,7 +600,7 @@ export function AuditFeed({
</div>
) : null}
<div className="flex flex-wrap items-end gap-3 border-y border-border py-3">
<div data-canonical-toolbar="audit-activity" role="toolbar" aria-label="Audit filters" className="flex flex-wrap items-end gap-3 border-y border-border py-3">
{canUseAdvancedControls && !lockedAgentId && !lockedRunId ? (
<label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Agent</span>
+1 -1
View File
@@ -222,7 +222,7 @@ export function AuditRuns({
</div>
)}
<div className="flex flex-wrap items-end gap-3 border-y border-border py-3">
<div data-canonical-toolbar="audit-runs" role="toolbar" aria-label="Audit filters" className="flex flex-wrap items-end gap-3 border-y border-border py-3">
{lockedAgentId ? null : <label className="grid gap-1 text-(length:--text-micro) font-medium text-muted-foreground">
<span>Agent</span>
<Select value={agentId} onValueChange={(value) => updateFilter("agentId", value)}>
+1 -1
View File
@@ -403,7 +403,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
description="What your agents actually did with your apps, newest first. Each line is one decision — allowed, blocked, asked first, waiting, or failed."
/>
<div className="flex flex-wrap items-center gap-2">
<div data-canonical-toolbar="tool-audit" role="toolbar" aria-label="Tool audit filters" className="flex flex-wrap items-center gap-2">
<Select value={app} onValueChange={setApp}>
<SelectTrigger className="w-40">
<SelectValue placeholder="App" />
+58 -3
View File
@@ -13,9 +13,11 @@ const toolsApiMock = vi.hoisted(() => ({
connectApp: vi.fn(),
startOAuth: vi.fn(),
finishApp: vi.fn(),
getConnection: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
const popupAssignMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: navigateTopLevelMock }));
// The tab uses `useNavigate` from the app router (PAP-11088 draft hand-off),
@@ -173,13 +175,19 @@ function oauthConnectResult(startUrl: string | null = null): ConnectToolAppResul
describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
let container: HTMLDivElement;
let mountedRoot: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => mountedRoot?.unmount());
mountedRoot = null;
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
@@ -188,6 +196,7 @@ describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
async function render() {
const root = createRoot(container);
mountedRoot = root;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
@@ -215,13 +224,19 @@ describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
let container: HTMLDivElement;
let mountedRoot: ReturnType<typeof createRoot> | null = null;
beforeEach(() => {
popupAssignMock.mockReset();
vi.spyOn(window, "open").mockReturnValue({ closed: false, location: { assign: popupAssignMock, href: "about:blank" }, focus: vi.fn(), close: vi.fn(), sessionStorage: window.sessionStorage } as unknown as Window);
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => mountedRoot?.unmount());
mountedRoot = null;
document.body.removeChild(container);
document.body.innerHTML = "";
vi.restoreAllMocks();
@@ -230,6 +245,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
async function render() {
const root = createRoot(container);
mountedRoot = root;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
@@ -405,7 +421,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
credentialValues: {},
});
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=redacted",
);
expect(container.textContent).toContain("Unverified server");
@@ -413,6 +429,45 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
expect(container.textContent).not.toContain("Review actions for notion");
});
it("verifies a completed sign-in and advances the original tab", async () => {
const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.mocked(window.open).mockReturnValue(popup as unknown as Window);
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("https://mcp.notion.com/authorize"));
toolsApiMock.getConnection.mockResolvedValue({ id: "conn-1", status: "draft" });
await act(async () => buttonStartingWith("Check actions")!.click());
await flushReact();
await flushReact();
popup.location.href = `${window.location.origin}/CO/apps/other-connection/permissions?success=1`;
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(toolsApiMock.getConnection).not.toHaveBeenCalled();
// Simulate AppDetail consuming success=1 before the opener polls.
popup.location.href = `${window.location.origin}/CO/apps/conn-1/permissions`;
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(toolsApiMock.getConnection).toHaveBeenCalledWith("conn-1");
expect(mockNavigate).not.toHaveBeenCalled();
toolsApiMock.getConnection.mockResolvedValue({ id: "conn-1", status: "active" });
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/permissions");
expect(popup.close).toHaveBeenCalled();
});
it.each(["closed", "denied", "failed"])("offers retry after pasted-config sign-in is %s", async (outcome) => {
const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.mocked(window.open).mockReturnValue(popup as unknown as Window);
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("https://mcp.notion.com/authorize"));
await act(async () => buttonStartingWith("Check actions")!.click());
await flushReact();
await flushReact();
if (outcome === "closed") popup.closed = true;
else popup.location.href = `${window.location.origin}/CO/apps/connect?resume=conn-1&oauth=${outcome}`;
await act(async () => { window.dispatchEvent(new Event("focus")); });
expect(buttonStartingWith("Try again")).toBeTruthy();
expect(mockNavigate).not.toHaveBeenCalled();
expect(toolsApiMock.getConnection).not.toHaveBeenCalled();
});
it("uses the background Cloud handoff returned with an imported OAuth connection", async () => {
const session = "imported_background_session_1234";
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({
@@ -437,7 +492,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
body: JSON.stringify({ session }),
}));
await vi.waitFor(() => {
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://provider.example.test/authorize?state=imported",
);
});
@@ -473,7 +528,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
expect(toolsApiMock.connectApp).toHaveBeenCalledTimes(1);
expect(toolsApiMock.startOAuth).toHaveBeenCalledTimes(1);
expect(toolsApiMock.startOAuth).toHaveBeenCalledWith("conn-1");
expect(navigateTopLevelMock).toHaveBeenCalledWith(
expect(popupAssignMock).toHaveBeenCalledWith(
"https://mcp.notion.com/authorize?state=retry-redacted",
);
});
+49 -12
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, KeyRound, Loader2, ShieldCheck } from "lucide-react";
@@ -15,8 +15,8 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { toolsApi } from "@/api/tools";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { useAuthorizationWindow } from "@/hooks/useAuthorizationWindow";
import { readOAuthWindowOutcome } from "@/lib/oauthWindowOutcome";
import { useNavigate } from "@/lib/router";
import {
OAuthConnectStateScreen,
@@ -114,6 +114,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
const [oauthError, setOAuthError] = useState<string | null>(null);
const [authorizationHost, setAuthorizationHost] = useState<string | null>(null);
const authorization = useAuthorizationWindow();
const importMutation = useMutation({
mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }),
onSuccess: (result) => {
@@ -129,23 +130,54 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
handoff?: ToolOAuthStartResult["handoff"],
) => {
try {
const target = await prepareOAuthNavigation({ authorizationUrl, handoff });
if (target.kind === "reauthentication" && handoff) {
savePendingCloudHandoff(handoff.session);
}
const target = await authorization.open({ authorizationUrl, handoff });
setAuthorizationHost(target.host);
setOAuthPhase(target.kind === "authorization" ? "redirecting" : "starting");
navigateTopLevel(target.url);
setOAuthPhase("redirecting");
} catch (error) {
setOAuthPhase("error");
setOAuthError(describeError(error).body);
}
};
const { windowRef: authorizationWindow, cancel: closeAuthorization } = authorization;
const oauthConnectionId = connectResult?.connectionId;
useEffect(() => {
if (oauthPhase !== "redirecting" || !oauthConnectionId) return;
let cancelled = false;
let checking = false;
const check = async () => {
if (cancelled || checking) return;
const outcome = readOAuthWindowOutcome(authorizationWindow.current, oauthConnectionId);
if (outcome === "waiting") return;
if (outcome !== "connected") {
closeAuthorization();
setOAuthPhase("error");
setOAuthError(outcome === "closed" ? "The sign-in window closed. Try again to finish connecting."
: outcome === "denied" ? "Sign-in was cancelled. Try again when you’re ready."
: "Sign-in failed. Try again to finish connecting.");
return;
}
checking = true;
try {
const saved = await toolsApi.getConnection(oauthConnectionId);
if (!cancelled && saved.status === "active") {
cancelled = true;
closeAuthorization();
navigate(`/apps/${oauthConnectionId}/permissions`);
}
} catch { /* A later focus or poll can retry verification. */ }
finally { checking = false; }
};
const timer = window.setInterval(() => void check(), 1000);
window.addEventListener("focus", check);
return () => { cancelled = true; window.clearInterval(timer); window.removeEventListener("focus", check); };
}, [oauthPhase, oauthConnectionId, authorizationWindow, closeAuthorization, navigate]);
const oauthStartMutation = useMutation({
mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId),
onSuccess: (start) => void openAuthorizationPage(start.authorizationUrl, start.handoff),
onError: (error) => {
authorization.cancel();
setOAuthPhase("error");
setOAuthError(
describeError(error).body,
@@ -170,6 +202,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
setAuthorizationHost(null);
setOAuthError(null);
if (result.auth.manualClientRequired) {
authorization.cancel();
setOAuthPhase("error");
setOAuthError(
"This server requires OAuth client details from its provider settings. Continue in setup to add them.",
@@ -185,12 +218,14 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
}
return;
}
authorization.cancel();
const defaults: Record<string, boolean> = {};
for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true;
for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = true;
setEnabled(defaults);
setActivatedName(null);
},
onError: () => authorization.cancel(),
});
const finishMutation = useMutation({
@@ -229,7 +264,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
: typeof connectResult.connection.transportConfig?.url === "string"
? connectResult.connection.transportConfig.url
: "";
return (
return (<>
{authorization.fallback}
<OAuthConnectStateScreen
identity={{
name: connectResult.application.name,
@@ -239,6 +275,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
error={oauthError}
authorizationHost={authorizationHost}
onRetry={() => {
authorization.reserve();
setOAuthError(null);
setAuthorizationHost(null);
setOAuthPhase("starting");
@@ -247,7 +284,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
onBack={() => navigate(`/apps/${connectResult.connectionId}/permissions`)}
onCancel={() => navigate("/apps")}
/>
);
</>);
}
return (
@@ -331,7 +368,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
}
checking={connectMutation.isPending && connectMutation.variables?.name === draft.name}
canCheck={Boolean(url) && missingFields.length === 0}
onCheck={url ? () => connectMutation.mutate(draft) : undefined}
onCheck={url ? () => { authorization.reserve(); connectMutation.mutate(draft); } : undefined}
/>
);
})}
@@ -1,5 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { Hexagon, Package } from "lucide-react";
import { EmptyState } from "@/components/EmptyState";
import { ArtifactEmptyGraphic } from "@/components/artifacts/v1/ArtifactEmptyGraphic";
import { ArtifactsV1Filters, type ArtifactsV1View } from "@/components/artifacts/v1/ArtifactsV1Filters";
import { ProjectsToolbar } from "@/components/ProjectsToolbar";
import capBlack from "@/artifact-directions/assets/cap-shot-black.svg";
import capForest from "@/artifact-directions/assets/cap-shot-forest.svg";
import contactSheet from "@/artifact-directions/assets/contact-sheet-interface-craft.svg";
@@ -52,11 +57,11 @@ const meta = {
},
},
paperclip: {
capabilities: ["documents-artifacts/library", "documents-artifacts/work-products", "documents-artifacts/attachments"],
capabilities: ["documents-artifacts/library", "documents-artifacts/work-products", "documents-artifacts/attachments", "projects/lifecycle"],
status: "proposed",
implementation: "production",
boundary: "fixtures",
notes: "Mounts the production ArtifactStackTile, ArtifactSingleTile, ArtifactRow and ArtifactRowGroup from ui/src/components/artifacts/v1 on fixture subjects with the lab's drawings as pictures. Hover a tile to see the page fan and the artwork rise (not under reduced motion). No API requests; links go nowhere.",
notes: "Mounts the production ArtifactStackTile, ArtifactSingleTile, ArtifactRow and ArtifactRowGroup from ui/src/components/artifacts/v1 on fixture subjects with the lab's drawings as pictures. Hover a tile to see the page fan and the artwork rise (not under reduced motion). EmptyProjects and EmptyArtifactViews mount the shared production empty state with the large brand stack or matching file face. The project CTA reports intent locally. No API requests; links go nowhere.",
},
},
} satisfies Meta;
@@ -125,3 +130,24 @@ export const GroupedRows: Story = {
</Stage>
),
};
export const EmptyProjects: Story = {
render: function Render() {
const [created, setCreated] = useState(false);
const [query, setQuery] = useState("");
return <div className="bg-background p-6 text-foreground">
<ProjectsToolbar query={query} onQueryChange={setQuery} sortField="name" sortDir="asc" onSortChange={() => {}} onNewProject={() => setCreated(true)} />
<EmptyState icon={Hexagon} graphic={<ArtifactEmptyGraphic stack />} message="No projects yet." action="Create project" hideActionIcon onAction={() => setCreated(true)} />
{created && <p role="status">Create project requested (story fixture).</p>}
</div>;
},
};
export const EmptyArtifactViews: Story = {
render: function Render() {
const [view, setView] = useState<ArtifactsV1View>("tasks");
return <div className="bg-background p-6 text-foreground">
<ArtifactsV1Filters view={view} onViewChange={setView} />
<EmptyState icon={Package} graphic={<ArtifactEmptyGraphic stack={view === "tasks"} kind={view === "tasks" || view === "all" ? "file" : view === "text" ? "code" : view} />} message={view === "tasks" ? "No artifact stacks yet." : "No artifacts of this type yet."} />
</div>;
},
};
@@ -106,8 +106,8 @@ function ProjectsToolbarSpecimen() {
const [field, setField] = useState<ProjectSortField>("name");
const [direction, setDirection] = useState<ProjectSortDir>("asc");
const [action, setAction] = useState("");
return <Specimen title="Projects toolbar" source="ProjectsToolbar · shared project search, sort direction and Add Project action used by the Projects page.">
<ProjectsToolbar query={query} onQueryChange={setQuery} sortField={field} sortDir={direction} onSortChange={(nextField, nextDirection) => { setField(nextField); setDirection(nextDirection); }} onNewProject={() => setAction("Add Project requested · local preview only")} />
return <Specimen title="Projects toolbar" source="ProjectsToolbar · shared project search, sort direction and Create project action used by the Projects page.">
<ProjectsToolbar query={query} onQueryChange={setQuery} sortField={field} sortDir={direction} onSortChange={(nextField, nextDirection) => { setField(nextField); setDirection(nextDirection); }} onNewProject={() => setAction("Create project requested · local preview only")} />
<output className="block text-xs" aria-live="polite">Search: {query || "All projects"} · Sort: {field} · Direction: {direction}{action ? ` · ${action}` : ""}</output>
</Specimen>;
}