diff --git a/.claude/launch.json b/.claude/launch.json index 7cbc05c40a..6258bdc8cc 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,9 +4,12 @@ { "name": "paperclip", "runtimeExecutable": "/bin/sh", - "runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"], + "runtimeArgs": [ + "-c", + "TMPDIR=/tmp pnpm dev" + ], "port": 3108, - "autoPort": false + "autoPort": true } ] } diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index 6d528bc99f..4bffe5f5eb 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -81,10 +81,11 @@ async function runOnboardingWizard(page: Page, companyName: string) { await page.getByPlaceholder("What is your team trying to achieve?").fill(MISSION); await page.getByRole("button", { name: /Confirm mission/ }).click(); - // Step 3: lead name (prefilled) → Next. - await page.waitForSelector('input[placeholder="Chief of staff"]', { - timeout: 15_000, - }); + // Step 3: the lead's role, then its name. The role gates "Next", and + // choosing one fills the name — so the walk only types here to override it. + await page.waitForSelector("#onboarding-agent-role", { timeout: 15_000 }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); await page.getByRole("button", { name: /^Next/ }).click(); // Step 4: adapter (claude_local default); heartbeat is intercepted. diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts index c872f5712b..d5b56929e4 100644 --- a/tests/e2e/nux-phase4-screenshots.spec.ts +++ b/tests/e2e/nux-phase4-screenshots.spec.ts @@ -83,7 +83,7 @@ test.describe("NUX Phase 4 visual QA", () => { // Step 2 advances via "Confirm mission" (creates the company + goal); // step 3 is the team-lead naming step of the capsule wizard. await page.getByRole("button", { name: /Confirm mission/ }).click(); - await page.waitForSelector('input[placeholder="Chief of staff"]', { + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000, }); await page.screenshot({ path: shot("04-hire-team-lead.png") }); diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index a29078a724..ecd077d332 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -71,7 +71,7 @@ test.describe("Onboarding wizard", () => { // "Confirm mission" creates the company + a company-level goal, then // advances to the team-lead naming step of the capsule wizard. await page.getByRole("button", { name: /Confirm mission/ }).click(); - await page.waitForSelector('input[placeholder="Chief of staff"]', { + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000, }); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 47053beb38..fb362d40e9 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -4,7 +4,8 @@ import { instrumentNavLog, } from "./helpers/onboarding-landing"; -const AGENT_NAME = "Chief of staff"; +/** The name the CEO role fills in — see AGENT_ROLE_LABELS. */ +const AGENT_NAME = "CEO"; const TASK_TITLE = "Paperclip onboarding"; test("captures planning mode UI for desktop and mobile", async ({ page }) => { @@ -64,8 +65,12 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { .fill("Capture planning mode visual evidence for the graduated task UI."); await page.getByRole("button", { name: /Confirm mission/ }).click(); - await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 }); - await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME); + // The lead is no longer pre-named. Choosing a role fills the name from the + // role's label, which is also what gates "Next". + await page.waitForSelector("#onboarding-agent-role", { timeout: 30_000 }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); + await expect(page.locator("#onboarding-agent-name")).toHaveValue(AGENT_NAME); await page.getByRole("button", { name: /^Next/ }).click(); await page.getByRole("button", { name: /^Connect$/ }).click(); diff --git a/tests/release-smoke/docker-auth-onboarding.spec.ts b/tests/release-smoke/docker-auth-onboarding.spec.ts index dd02b0e40f..031d5862d9 100644 --- a/tests/release-smoke/docker-auth-onboarding.spec.ts +++ b/tests/release-smoke/docker-auth-onboarding.spec.ts @@ -61,10 +61,12 @@ test.describe("Docker authenticated onboarding smoke", () => { .fill(MISSION); await page.getByRole("button", { name: "Confirm mission" }).click(); - // Step 3: name the team lead. - const leadNameInput = page.locator('input[placeholder="Chief of staff"]'); - await expect(leadNameInput).toBeVisible({ timeout: 20_000 }); - await leadNameInput.fill(AGENT_NAME); + // Step 3: give the team lead a role, then a name. The role gates "Next". + const roleSelect = page.locator("#onboarding-agent-role"); + await expect(roleSelect).toBeVisible({ timeout: 20_000 }); + await roleSelect.click(); + await page.getByRole("option", { name: "CEO", exact: true }).click(); + await page.locator("#onboarding-agent-name").fill(AGENT_NAME); await page.getByRole("button", { name: "Next" }).click(); // Step 4: keep the default adapter and connect (hire) the lead. The diff --git a/ui/package.json b/ui/package.json index 6b626670ef..ae0c33915f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -60,6 +60,7 @@ "lexical": "0.49.0", "lucide-react": "^0.577.0", "mermaid": "^11.16.1", + "motion": "^12.42.2", "radix-ui": "^1.6.7", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/ui/src/components/AgentCapsule.test.tsx b/ui/src/components/AgentCapsule.test.tsx index 87eff2c090..87285eb032 100644 --- a/ui/src/components/AgentCapsule.test.tsx +++ b/ui/src/components/AgentCapsule.test.tsx @@ -90,4 +90,28 @@ describe("AgentCapsule", () => { expect(render().dataset.gradient).toBe("10"); expect(render().dataset.gradient).toBe("9"); }); + + it("traces the outline instead of cross-fading it when strokeDraw is set", () => { + // The cross-fade renders a bordered ; the trace renders an SVG rect + // animated from pathLength 0 to 1. + const cap = render(); + expect(cap.querySelector("svg rect")).not.toBeNull(); + expect(cap.querySelector(".agent-cap-stroke")).toBeNull(); + }); + + it("keeps the cross-fade when strokeDraw is not asked for", () => { + // Everywhere outside the onboarding arc the quieter default applies. + const cap = render(); + expect(cap.querySelector(".agent-cap-stroke")).not.toBeNull(); + expect(cap.querySelector("svg rect")).toBeNull(); + }); + + it("holds the dashed outline until the trace finishes", () => { + // Fading the dashed layer on the usual schedule would leave the capsule + // briefly outline-less in the middle of its own birth. + const cap = render(); + const dashed = cap.querySelector(".agent-cap-dash") as HTMLElement; + expect(dashed).not.toBeNull(); + expect(dashed.style.transitionDelay).not.toBe(""); + }); }); diff --git a/ui/src/components/AgentCapsule.tsx b/ui/src/components/AgentCapsule.tsx index 817a9dbb76..b696a3a9b6 100644 --- a/ui/src/components/AgentCapsule.tsx +++ b/ui/src/components/AgentCapsule.tsx @@ -1,3 +1,4 @@ +import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { cn } from "@/lib/utils"; @@ -60,10 +61,24 @@ export interface AgentCapsuleProps size?: AgentCapsuleSizePreset | { width: number; height: number }; /** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */ glow?: AgentCapsuleGlow; + /** + * Render the slot→configured morph as a draw-on: the solid outline is traced + * around the perimeter over the still-visible dashed outline, which fades + * once the draw completes, instead of the two layers cross-fading. + * + * This is the agent's "birth" moment in the onboarding wizard, where the + * customer has just named it and the capsule should read as being drawn into + * existence. Everywhere else the cross-fade is the right, quieter default. + */ + strokeDraw?: boolean; /** Accessible label; defaults to a description of the state. */ "aria-label"?: string; } +/** Duration of the strokeDraw perimeter trace; the dashed layer fades after it. */ +const STROKE_DRAW_SECONDS = 0.9; +const STROKE_DRAW_EASE = [0.16, 1, 0.3, 1] as const; + /** Normalize a (possibly out-of-range) gradient index to 1…AGENT_GRADIENT_COUNT. */ function normalizeGradient(gradient: number): number { const n = Math.trunc(gradient); @@ -75,12 +90,15 @@ export function AgentCapsule({ gradient = 1, size = "md", glow = "green", + strokeDraw = false, className, style, "aria-label": ariaLabel, ...rest }: AgentCapsuleProps) { const dims = typeof size === "string" ? SIZE_PRESETS[size] : size; + const reducedMotion = useReducedMotion(); + const drawn = state === "configured" || state === "online"; const idx = normalizeGradient(gradient); const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`; @@ -107,16 +125,53 @@ export function AgentCapsule({ "agent-cap-dash agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-dashed border-muted-foreground/60", state === "slot" ? "agent-cap-slot opacity-100" : "opacity-0", )} + // In strokeDraw mode the dashed outline stays put while the solid one + // is traced over it, and only then fades — otherwise the capsule would + // be briefly outline-less midway through its own birth. + style={ + strokeDraw && state === "configured" && !reducedMotion + ? { transitionDelay: `${STROKE_DRAW_SECONDS}s` } + : undefined + } /> - {/* Solid stroke — agent configured, not yet live. Cross-fades in on top - of the dashed layer, then out as the liquid rises. */} -