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

{title}

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