mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - New customers meet it through onboarding, whose last three steps run
inside the tenant: create an agent, connect a model, review
> - The connect step is the one that decides whether the agent can run
at all, and it had drifted — three contributors changed it in parallel,
and its visual language no longer matched the rest of the flow
> - It also could not be looked at without a provisioned stack, so
defects in it were only found by walking a real signup, and the review
step behind it could not be reached at all when it failed
> - This pull request brings the visual work onto the sign-in behaviour
that already shipped, and adds Storybook coverage for all three steps
> - The benefit is that the step is easier to read, and that it can now
be inspected and driven before it ships rather than after
## Linked Issues or Issue Description
**What existing behavior does this improve?**
The onboarding connect-a-model step. It presented the model choice as a
dropdown plus an "Advanced settings" disclosure, and put each credential
type in a different place, so the controls below moved whenever the
choice changed.
**Subsystem affected**
Tenant onboarding wizard (`ui/src/components/OnboardingWizard.tsx`) and
its Storybook coverage.
**Current behavior**
The step offered every registered adapter through a disclosure.
Credential entry appeared in a different shape per source. None of the
three agent-arc steps could be rendered outside a provisioned cloud
stack, so the sign-in panel and the review step were only reachable by
walking a real signup.
**Proposed behavior**
Two brand tiles for the recommended sources, a link that switches
between subscription and API-key credentials, and one canvas that holds
whichever input the current choice needs. Storybook stories mount the
real wizard against fixtures and walk it forward, so every step and its
states can be inspected locally.
**Reason and benefit**
The step reads as one decision rather than three scattered ones, and its
furniture stays still while the choice changes. The stories mean a
regression in it is visible before release instead of during a signup.
**Breaking changes**
No API or schema change. One behavioural narrowing, described under
Risks.
## What Changed
- Replaces the adapter dropdown and "Advanced settings" disclosure with
`ModelSourceTiles` — brand tiles for Claude Code and Codex.
- Adds `CredentialModeLink`, a text toggle between subscription sign-in
and API keys, replacing the disclosure.
- Adds `ConnectInputCanvas`: one surface that holds the sign-in panel or
the API-key field and resizes between them, so the Connect button below
does not move.
- Keeps the existing sign-in behaviour unchanged. `AgentConfigForm`
changes are presentation only — the provider name in the title, the CTA
wording, and `space-y` to `gap`. No change to the login mutations,
queries, or session handling.
- Restores the sleep marks on the dormant agent for the two steps before
the hire.
- Adds Storybook stories for all three agent-arc steps, with fixtures
for the environments, auth signal, both adapters' login flows, and the
hire.
- Copy: names the provider being signed in to ("Sign in to
Anthropic"/"Sign in to OpenAI"), and drops "Clippy" from the agent-name
helper text.
## Verification
- `pnpm vitest run src/components storybook` in `ui/` — 139 tests over
the touched suites, 1986 across `src/components`.
- `pnpm typecheck` in `ui/` — clean.
- Storybook, `Onboarding/Agent arc`: walk each story. Step 1 has no Back
button, steps 2 and 3 do.
- The sign-in gate: on `Connect a model`, press Connect without signing
in. It holds on step 2 and reports "No working authentication was
found." On `Review`, which fixtures an authenticated signal, Connect
reaches the review step.
- Both providers' login flows: press Sign in on the Claude tile for the
authorization URL and browser-code field, and on the Codex tile for the
device URL and code.
- The Claude sign-in was also walked end to end on a staging tenant,
including the OAuth redirect and pasting the code back.
## Risks
- **Onboarding now offers two model sources instead of every registered
adapter.** `ModelSourceTiles` is fed the `recommended` set, which is
`claude_local` and `codex_local`; Gemini, Cursor, Grok, Kimi, OpenCode
and Paperclip Runner are no longer selectable *during onboarding*. This
is deliberate. The full list is unchanged in agent settings, which is
where an adapter can still be switched after the agent exists, and
adding a source back is one `recommended: true` in
`adapter-display-registry.ts`. Flagging it because it is the one
behavioural narrowing here and it is not visible from the diffstat.
- The API key entered on this step is held in component state and
deliberately never written to the onboarding draft, because that draft
is `localStorage`. A customer who leaves mid-step re-enters the key;
that is the intended trade.
- Storybook-only risk: the fixtures now answer the environment test from
the story's auth state. If a future change moves the hire's gate off the
`adapter_auth_missing` check code, the stories would keep passing while
the product regressed. The gate is asserted in the adapter packages' own
tests, not here.
- Motion changes are low risk and reversible: the input canvas animates
its contents only, and its container was deliberately left unanimated
after an animated wrapper clipped the sign-in panel.
## Model Used
Claude Opus 5 (`claude-opus-5`), via Claude Code with extended thinking,
tool use, and browser-driven verification of the Storybook stories.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.9 KiB
TypeScript
174 lines
7.9 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
/**
|
||
* NUX Phase 4 — visual QA screenshot capture.
|
||
*
|
||
* Boots a throwaway local_trusted instance (see playwright.config.ts webServer)
|
||
* and captures screenshots of every surface integrated by NUX Phases 1–3:
|
||
* - "Build a new company" step 1 (company name) + step 2 (mission)
|
||
* - Team-lead hire step (capsule wizard, PAP-125)
|
||
* - Onboarding front door (path picker)
|
||
* - "Add agents to your org" growth intake
|
||
* - Conference Room (BoardChat) shell + composer + activity feed
|
||
* - Artifacts page
|
||
*
|
||
* These are structural/rendering checks — LLM-dependent streaming (CEO chat
|
||
* responses, hiring-plan generation) is verified separately on an LLM-backed
|
||
* instance. Screenshots land in ./nux-phase4-shots for upload as evidence.
|
||
*/
|
||
|
||
// Write under the gitignored test-results dir so re-runs leave no untracked
|
||
// noise; screenshots are uploaded to the issue as QA evidence, not committed.
|
||
const SHOT_DIR = path.join(__dirname, "test-results", "nux-phase4-shots");
|
||
|
||
function shot(name: string) {
|
||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||
return path.join(SHOT_DIR, name);
|
||
}
|
||
|
||
async function openWizard(page: import("@playwright/test").Page) {
|
||
await page.goto("/onboarding");
|
||
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Organization|Add Agent/ });
|
||
if (await startBtn.count()) {
|
||
await startBtn.first().click();
|
||
}
|
||
}
|
||
|
||
test.describe("NUX Phase 4 visual QA", () => {
|
||
test("captures every integrated surface", async ({ page }) => {
|
||
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
|
||
// experimental flag on for this throwaway instance before driving them.
|
||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||
data: { enableConferenceRoomChat: true },
|
||
});
|
||
expect(flagRes.ok()).toBe(true);
|
||
|
||
const consoleErrors: string[] = [];
|
||
page.on("console", (msg) => {
|
||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||
});
|
||
page.on("pageerror", (err) => consoleErrors.push("PAGEERROR: " + err.message));
|
||
|
||
const baseUrl =
|
||
"http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199");
|
||
|
||
// ── Section A: create-company path (name → mission → hire) ────────────
|
||
await openWizard(page);
|
||
// Front door shows when the wizard doesn't open directly on the create
|
||
// path (e.g. another spec already created a company on this instance).
|
||
const createCard = page.getByRole("button", { name: /Build a new organization/ });
|
||
if (await createCard.count()) {
|
||
await createCard.first().click();
|
||
}
|
||
await expect(
|
||
page.getByRole("heading", { name: "What is the name of your organization?" }),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await page.getByPlaceholder("e.g. Northwind Labs").fill("QA Robotics");
|
||
await page.screenshot({ path: shot("02-create-name.png") });
|
||
|
||
await page.getByRole("button", { name: /^Continue/ }).click();
|
||
// Step 1's "Next" creates the company and goes straight to the team lead.
|
||
// The mission screenshot that sat here is gone with the step it captured.
|
||
await page.waitForSelector("#onboarding-agent-name", {
|
||
timeout: 30_000,
|
||
});
|
||
await page.screenshot({ path: shot("04-hire-team-lead.png") });
|
||
|
||
// The company just created anchors the route-scoped sections below.
|
||
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
|
||
expect(companiesRes.ok()).toBe(true);
|
||
const companies = await companiesRes.json();
|
||
const qaCompany = (Array.isArray(companies) ? companies : []).find(
|
||
(c: { name: string }) => c.name === "QA Robotics",
|
||
);
|
||
expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy();
|
||
const prefix: string = qaCompany.issuePrefix;
|
||
|
||
// ── Section B: front door + growth intake ─────────────────────────────
|
||
await page.evaluate(() => window.localStorage.clear());
|
||
await openWizard(page);
|
||
// Reach the full-screen front door (step 0): either it shows directly or
|
||
// the naming step's Back returns to it.
|
||
//
|
||
// That control used to be a "← Back to start" text link. The naming step now
|
||
// wears the same footer pair as the steps after it, so its Back is labelled
|
||
// like theirs — it still lands on the front door, because the front door is
|
||
// what sits behind step 1.
|
||
//
|
||
// Exact, because the progress strip's segments are buttons with their own
|
||
// labels and an unanchored /Back/ would match more than one.
|
||
if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
|
||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||
}
|
||
await expect(
|
||
page.getByRole("heading", { name: "Welcome to Paperclip" }),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
await expect(
|
||
page.getByRole("heading", { name: "Build a new organization" }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("heading", { name: "Add agents to your org" }),
|
||
).toBeVisible();
|
||
await page.screenshot({ path: shot("01-front-door.png") });
|
||
|
||
await page.getByRole("button", { name: /Add agents to your org/ }).click();
|
||
// The grow path shares step 1 (company name) before its step-2 intake.
|
||
await expect(
|
||
page.getByRole("heading", { name: "What is the name of your organization?" }),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
await page.getByPlaceholder("e.g. Northwind Labs").fill("QA Robotics Grow");
|
||
await page.getByRole("button", { name: /^Continue/ }).click();
|
||
await expect(
|
||
page.getByRole("heading", { name: /Tell us about your team/ }),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
await page.screenshot({ path: shot("05-growth-intake.png") });
|
||
|
||
// ── Section C: Conference Room (BoardChat) ────────────────────────────
|
||
// Visit the company dashboard first so CompanyContext selects the company
|
||
// from the route before we land on the board-chat surface.
|
||
await page.evaluate(() => window.localStorage.clear());
|
||
await page.goto(`/${prefix}/dashboard`);
|
||
await page.waitForLoadState("networkidle");
|
||
await page.goto(`/${prefix}/board-chat`);
|
||
await expect(page).toHaveURL(new RegExp(`/${prefix}/board-chat`));
|
||
// Composer renders once a company is selected. (Regression guard for the
|
||
// Rules-of-Hooks crash that previously blanked this page — see PAP-50.)
|
||
await expect(
|
||
page.getByPlaceholder("Ask anything about your organization..."),
|
||
).toBeVisible({ timeout: 20_000 });
|
||
await page.waitForTimeout(2_000); // let welcome bubble + suggestion chips stage in
|
||
await page.screenshot({ path: shot("06-board-chat.png") });
|
||
|
||
// ── Section D: Artifacts ──────────────────────────────────────────────
|
||
await page.goto(`/${prefix}/artifacts`);
|
||
await expect(page).toHaveURL(new RegExp(`/${prefix}/artifacts`));
|
||
await page.waitForLoadState("networkidle");
|
||
await page.waitForTimeout(1_000);
|
||
await page.screenshot({ path: shot("07-artifacts.png") });
|
||
|
||
for (const f of [
|
||
"01-front-door.png",
|
||
"02-create-name.png",
|
||
"04-hire-team-lead.png",
|
||
"05-growth-intake.png",
|
||
"06-board-chat.png",
|
||
"07-artifacts.png",
|
||
]) {
|
||
const p = shot(f);
|
||
expect(fs.existsSync(p), `missing ${f}`).toBe(true);
|
||
expect(fs.statSync(p).size, `empty ${f}`).toBeGreaterThan(1_000);
|
||
}
|
||
|
||
// No React Rules-of-Hooks / render crashes on any surface we visited.
|
||
const hookErrors = consoleErrors.filter(
|
||
(e) => /Rendered more hooks|change in the order of Hooks/i.test(e),
|
||
);
|
||
expect(hookErrors, hookErrors.join("\n")).toHaveLength(0);
|
||
});
|
||
});
|