diff --git a/DESIGN.md b/DESIGN.md
index 3099f7bfa28..026e1f6b633 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -951,7 +951,7 @@ Standard. Artifact type filters also support Compact for dense standalone uses.
patterns. Tabs contains only Playground, Standard and Compact. Playground
compares the two sizes with local fixture state; narrow width remains a control.
-Accepted toolbar foundation for Design Proposals and the opt-in sandbox:
+Accepted toolbar foundation for all production inline collection and audit toolbars:
- Use the shared `ToolbarSearch` and 44px control height, with one token-driven
border, radius and horizontal padding across Tasks, Decisions, Skills and
@@ -977,6 +977,31 @@ Further consolidation proposals, pending visual review:
Keep panel-switching tab semantics separate from exclusive collection
filters. Closable document tabs retain their additional behavior.
+### Collection empty states and roster selection
+
+Projects uses the large artifact stack with a dark grey Paperclip mark on an off-white
+image front. The empty-state action is “Create project”. Artwork, message and
+primary action have 20px gaps (`gap-5`). Hovering or keyboard-focusing the action
+fans the same artwork as hovering the graphic. Artifacts uses that stack for
+Tasks and the corresponding single full-size artifact for each other type filter.
+
+Agent lists give chat and status their own vertically centered cells. Chat has a
+screen-reader-only column label and a MessageCircleCheck icon action. Hover and
+focus highlight the entire row; the identity link has no separate background.
+Bulk actions portal to a fixed card centered in the viewport, 48px above the
+bottom safe area, with the shared border and a soft shadow. The card retains its
+contents during the quintic rise/drop transition; reduced motion removes travel.
+Selection never inserts space above the roster.
+
+Agent Overview's Profile uses the bulk-edit avatar/name chips for reporting
+relationships. Within raised modules, their background uses the Skills chip
+surface so they remain distinct from the module. Agent chips have hover/focus
+states and link to the agent's overview. Reports to precedes Direct reports:
+No manager opens the manager picker; an assigned manager has a linked chip and
+an adjacent edit control. The picker disables self/descendant choices to prevent
+reporting loops. The editable heading sizes to its live text so the primary-agent
+crown stays beside the name throughout editing.
+
### Segmented controller comparison
`Design proposals / Components / Segmented controllers` owns the segmented
diff --git a/maps/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md
index f5b980383fa..532cf1a1b59 100644
--- a/maps/feature-map/connection-setup.md
+++ b/maps/feature-map/connection-setup.md
@@ -35,7 +35,7 @@ policies use the ordinary Permissions screen. See [connector notes](../../doc/co
an empty explicit selection and company-wide access must not be confused.
- `credentials`: `connection-setup/credentials` — provider sign-in, key, or generic MCP URL follows its supported
setup method; credentials are not retained in browser draft storage.
-- `handoff`: `connection-setup/handoff` — provider popup/full-page callback returns to the correct setup
+- `handoff`: `connection-setup/handoff` — provider sign-in opens in a separate tab/window; its callback returns to the correct setup
host with the original task intent and identity choice intact.
- `draft-recovery`: `connection-setup/draft-recovery` — cancel, Back, Save & exit, reload, Finish setup, and reconnect
preserve the exact draft rather than creating duplicate connections.
@@ -63,7 +63,7 @@ catalog setup and a generic endpoint may use different flows.
### `task-request`
Open the task's pending connection request and its setup action as the named
-user. Setup can open in a task dialog and may use an OAuth popup. Follow the
+user. Setup can open in a task dialog; provider sign-in always uses a separate window/tab. Follow the
request's original link if setup falls back to a standalone page.
### `resume-connection`
diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts
index 5b4679b6ec4..6ebe799976c 100644
--- a/tests/e2e/chat-adapters-ui-providers.spec.ts
+++ b/tests/e2e/chat-adapters-ui-providers.spec.ts
@@ -206,7 +206,7 @@ test.describe.serial("native chat adapter UI", () => {
await expectCompletedSlackSetupCloses(page, seed);
expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1);
});
- test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => {
+ test("Slack: a failed manager refresh opens workspace authorization in a separate tab", async ({ page }) => {
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true });
let refreshFailed = false;
@@ -223,12 +223,16 @@ test.describe.serial("native chat adapter UI", () => {
authorizationRequests++;
await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } });
});
- await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" }));
+ await page.context().route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" }));
await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`);
await expect(page.getByRole("alert")).toBeVisible();
+ const authorizationOpened = page.context().waitForEvent("page");
await page.getByRole("button", { name: "Reconnect Slack workspace", exact: true }).click();
+ const authorizationPage = await authorizationOpened;
await expect.poll(() => authorizationRequests).toBe(1);
- await expect(page).toHaveURL("https://slack.test/manager-consent");
+ await expect(authorizationPage).toHaveURL("https://slack.test/manager-consent");
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/chat/connect\\?provider=slack`));
+ await authorizationPage.close();
});
test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => {
const slack = PROVIDERS.find(provider => provider.provider === "slack")!;
@@ -321,7 +325,7 @@ test.describe.serial("native chat adapter UI", () => {
const rootUrl = new URL(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`, test.info().project.use.baseURL).href;
const callbackUrl = new URL("/api/chat-slack/oauth/callback?state=fixture-state&code=fixture-code", rootUrl).href;
let callbackRequests = 0;
- await page.route("**/api/chat-slack/oauth/callback?**", async route => {
+ await page.context().route("**/api/chat-slack/oauth/callback?**", async route => {
callbackRequests += 1;
if (callbackRequests === 1) {
const body = oauthCallbackInterstitialHtml();
@@ -371,13 +375,19 @@ test.describe.serial("native chat adapter UI", () => {
await page.goto(rootUrl);
expect(mock.slackInstallations).toBe(1);
const installation = await holdSetupRequest("install");
+ const retryWindowOpened = page.context().waitForEvent("page");
try {
await page.getByRole("button", { name: "Install in Slack", exact: true }).click();
await installation.requested;
await expectNavigationLocked();
} finally { installation.release(); }
- await expect(page.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible();
- await page.getByRole("link", { name: "Approve installation" }).click();
+ const retryConsentWindow = await retryWindowOpened;
+ await expect(retryConsentWindow.getByRole("heading", { name: "Fixture Slack consent" })).toBeVisible();
+ await expect(page).toHaveURL(rootUrl);
+ await retryConsentWindow.getByRole("link", { name: "Approve installation" }).click();
+ await expect.poll(() => callbackRequests).toBe(2);
+ await retryConsentWindow.close();
+ await page.bringToFront();
await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Open Slack app Settings" })).toBeHidden();
await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toHaveAttribute("href", "https://app.slack.com/client/TE2E/DE2E");
diff --git a/tests/e2e/project-repositories.spec.ts b/tests/e2e/project-repositories.spec.ts
index e13640dc834..9631db41fdc 100644
--- a/tests/e2e/project-repositories.spec.ts
+++ b/tests/e2e/project-repositories.spec.ts
@@ -6,7 +6,7 @@ test("project creation and repository configuration persist on a short mobile vi
expect(createdCompany.ok()).toBe(true);
const company = await createdCompany.json();
await page.goto(`/${company.issuePrefix}/projects`);
- await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
+ await page.getByRole("button", { name: "Create project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("textbox", { name: "Project name" })).toBeFocused();
await dialog.getByRole("textbox", { name: "Project name" }).fill("Repository acceptance");
@@ -68,7 +68,7 @@ test("a short new-project dialog keeps actions visible and rejects unavailable s
await page.route(`**/api/companies/${company.id}/project-repositories`, (route) => route.fulfill({ json: { repositories: repos, connectionCount: 1, failedConnectionCount: 0 } }));
await page.setViewportSize({ width: 390, height: 420 });
await page.goto(`/${company.issuePrefix}/projects`);
- await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
+ await page.getByRole("button", { name: "Create project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "Project name" }).fill("Rejected draft");
await dialog.getByRole("button", { name: "Add GitHub repo", exact: true }).click();
diff --git a/tests/storybook-visual/project-repository-skills.spec.ts b/tests/storybook-visual/project-repository-skills.spec.ts
index 8377efc9120..38c6e49e9a9 100644
--- a/tests/storybook-visual/project-repository-skills.spec.ts
+++ b/tests/storybook-visual/project-repository-skills.spec.ts
@@ -49,7 +49,7 @@ test('repairs a missing branch without prompting for another connection', async
test('creating a project imports repository skills without another setup flow', async ({ page }) => {
await page.goto(story('create-project'));
- await page.getByRole('button', { name: 'Add Project', exact: true }).first().click();
+ await page.getByRole('button', { name: 'Create project', exact: true }).first().click();
const dialog = page.getByRole('dialog', { name: 'Create project', exact: true });
await dialog.getByRole('textbox', { name: 'Project name', exact: true }).fill('Repository project');
await dialog.getByRole('button', { name: 'Add GitHub repo', exact: true }).click();
@@ -57,9 +57,8 @@ test('creating a project imports repository skills without another setup flow',
await dialog.getByRole('button', { name: 'Create project', exact: true }).click();
await expect(dialog).toHaveCount(0);
await page.getByRole('link', { name: 'Skills', exact: true }).click();
- await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
- const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
- await expect(source.getByText('Code review', { exact: true })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Installed skills', exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: /^Code review by GitHub/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0);
});
diff --git a/tests/storybook-visual/toolbars-design.spec.ts b/tests/storybook-visual/toolbars-design.spec.ts
index a59bf4278f6..e71c49a0750 100644
--- a/tests/storybook-visual/toolbars-design.spec.ts
+++ b/tests/storybook-visual/toolbars-design.spec.ts
@@ -150,8 +150,8 @@ test("Projects toolbar searches, sorts and reports creation locally", async ({ p
await page.getByRole("button", { name: "Updated", exact: true }).click();
await page.keyboard.press("Escape");
await expect(preview.getByRole("status")).toContainText("Search: design · Sort: updated · Direction: desc");
- await preview.getByRole("button", { name: "Add Project", exact: true }).click();
- await expect(preview.getByRole("status")).toContainText("Add Project requested · local preview only");
+ await preview.getByRole("button", { name: "Create project", exact: true }).click();
+ await expect(preview.getByRole("status")).toContainText("Create project requested · local preview only");
});
diff --git a/ui/src/components/CollectionToolbar.tsx b/ui/src/components/CollectionToolbar.tsx
index e1cf96ce7f0..5d1bcdddfef 100644
--- a/ui/src/components/CollectionToolbar.tsx
+++ b/ui/src/components/CollectionToolbar.tsx
@@ -36,6 +36,7 @@ export function CollectionToolbar({
return (
-
+
+ {graphic ?
{graphic}
:
-
+
}
{title ? (
<>
{title}
diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx
index 69ae83adf70..c57da0cdfcb 100644
--- a/ui/src/components/InlineEditor.tsx
+++ b/ui/src/components/InlineEditor.tsx
@@ -17,6 +17,8 @@ interface InlineEditorProps {
className?: string;
placeholder?: string;
multiline?: boolean;
+ /** Keep a short heading's editor as wide as its live text, preserving adjacent controls. */
+ fitContent?: boolean;
imageUploadHandler?: (file: File) => Promise
;
/** Called when a non-image file is dropped onto the editor. */
onDropFile?: (file: File) => Promise;
@@ -82,6 +84,7 @@ function OwnedInlineEditor({
className,
placeholder = "Click to edit...",
multiline = false,
+ fitContent = false,
nullable = false,
imageUploadHandler,
onDropFile,
@@ -504,14 +507,14 @@ function OwnedInlineEditor({
}
if (editing) {
-
- return (
+ const editor = (
;
diff --git a/ui/src/components/ReportsToPicker.tsx b/ui/src/components/ReportsToPicker.tsx
index 706375c6bdc..5155f9641bd 100644
--- a/ui/src/components/ReportsToPicker.tsx
+++ b/ui/src/components/ReportsToPicker.tsx
@@ -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 (
-
+ }
-
+