Files
PaperClipAI/ui/src/components/AgentCapsule.tsx
T
TonioandClaude Opus 5 3d366ba15f Rebuild the onboarding agent arc on the prototype's step design (#11905)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The onboarding wizard in `ui/` hires that first agent. It runs three
steps: create the agent, connect a model, and review
> - A standalone prototype holds the agreed design for these steps.
#10786 ported that prototype, but #11067 reverted it in full because the
port deleted `OnboardingWizard.tsx` while four pull requests were
editing that file
> - Those four pull requests have since merged. The revert said the port
can "re-land incrementally", and this is that re-land
> - This pull request takes the presentational layer from the prototype
only. It keeps master's wizard as the source of behaviour, so the eight
onboarding fixes merged since the revert stay in place
> - The benefit is that the three agent steps match the agreed design,
and no merged fix is lost to get there

## Linked Issues or Issue Description

Refs #10786 — the first attempt to land this design.
Refs #11067 — the revert that asked for it to re-land in smaller steps.

No public issue exists for the re-land. The problem is described below.

**Subsystem affected**

The `ui` package. The change touches the onboarding wizard, the agent
capsule,
and one Storybook story. It adds four small presentational components
under
`ui/src/components/onboarding/`.

**Current behavior**

The wizard's agent steps do not match the prototype. Each step shows a
small
heading beside an icon, above a form. The agent capsule sits below that
heading and does not animate. The agent gets a name but no role, so
every
first agent is created as `ceo`.

The wizard also shows a five-segment progress bar on these steps. A
walker who
enters on the agent step cannot reach the first two segments, so two of
the
five can never be filled.

**Proposed behavior**

The three steps use the prototype's card, its centred display heading,
and its
footer. One capsule sits above the heading and stays mounted across all
three
steps, so it reads as one object being built rather than three screens
that
each show their own.

A three-segment strip counts these steps for a walker who enters on
them. The
full-length bar stays for a walker who starts at step one, so that count
never
restarts partway.

The agent step gains a role. The options come from the agent role enum,
not
from the prototype's mock list.

**Reason and benefit**

The design is agreed and already built once. Re-landing it
presentation-first
keeps the behaviour that master gained after the revert.

Sourcing roles from the enum matters. The prototype offers "Coder",
which is
not a valid role — the enum uses `engineer` — so a walker who picked it
would
fail validation at hire time.

**Breaking changes**

None. The wizard keeps its routes, its draft format, and its hire call.
The
draft gains one optional field, `agentRole`. A draft saved before this
change
loads without it and falls back to the default.

## What Changed

- Add `ui/src/components/onboarding/`: `Stepper`, `OnboardingCard`,
`OnboardingHeading`, `FooterNav`, `AgentPreview`, and shared motion
constants
- Rebuild wizard steps 3–5 on those parts: one card, the capsule above a
  centred heading, and one footer
- Hold one `AgentCapsule` across the three steps. It springs in once,
then
  morphs from dashed slot to traced outline to filled
- Add `strokeDraw` to `AgentCapsule`. It traces the outline instead of
  cross-fading it. The dashed layer holds until the trace ends
- Add a role select to the agent step. Choosing a role fills the name,
unless
  the walker typed one
- Show one progress indicator per run, not two
- Label strip segments by destination, not by number
- Add `motion` to the `ui` package
- Add a Storybook story for the strip and the capsule states

## Verification

Run the tests:

```
pnpm --filter @paperclipai/ui exec vitest run
pnpm --filter @paperclipai/ui exec tsc -p tsconfig.json --noEmit
```

4235 tests pass. The typecheck is clean.

To see the steps, start the app and open `/<PREFIX>/onboarding` for a
company
that has a company-level goal. The wizard opens on the agent step. Step
three
requires a hire.

Three absence assertions were checked by fault injection. Each one fails
when
the old behaviour returns:

- put the step counter back, and the "shows no step counter" test fails
- default `strokeDraw` to true, and the cross-fade test fails
- restore the timer gate on the strip, and the indicator test fails

## Risks

Low to medium.

`motion` is one new dependency in `ui`. #11067 gave dependency weight as
one
of three reasons to revert #10786, so this branch carries the smallest
set
that works. `motion` drives the step transitions and the capsule
choreography,
and three files import it.

An earlier revision of this branch also added `three` and
`@types/three`. Both
are removed. They existed for the 3D backdrop, which belongs to the auth
and
welcome screens rather than to these three steps, so nothing on this
branch
imported them.

The role select changes what the wizard sends. Before this change every
first
agent was hired as `ceo`. Now the walker chooses. The values come from
the
enum, so the server accepts all of them.

Steps 1 and 2 keep the older design. They do not run on the Cloud-first
path,
where the company already exists.

## Model Used

Claude Opus 5 (`claude-opus-5`), with extended thinking, tool use, and
code
execution. Used for the code, the tests, and this description.

## 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
- [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: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:41:53 -07:00

188 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* AgentCapsule — the brand "capsule is the agent" motif (PAP-118).
*
* A single agent is drawn as a tall pill (proportion 1:≥2, radius 9999px)
* that moves through three states as the agent comes to life:
*
* - `slot` — dashed outline, gently pulsing. An empty agent slot.
* - `configured` — solid stroke. Agent named / model picked, not yet live.
* - `online` — brand agent-gradient liquid rises to fill the capsule,
* which then breathes with an online-pulse ring (green by
* default, or blue via `glow="blue"`).
*
* The three states are drawn as stacked layers (a dashed outline, a solid
* stroke, and the rising liquid) that cross-fade by opacity. Because
* `border-style` is not animatable, the dashed→solid morph is realized as the
* dashed layer fading out while the solid layer fades in — so the SAME capsule
* can evolve in place across a flow (PAP-125, Option 4 wizard).
*
* The fill gradient comes from the live brand agent tokens
* `--agent-Na` (top) → `--agent-Nb` (bottom); pick which one with `gradient`
* (1–10). Size is a preset (`sm` | `md` | `lg`) or an explicit pixel pair so
* the component is reusable app-wide. `prefers-reduced-motion` is honored in
* CSS — the liquid rise, layer cross-fade and both pulses are skipped and the
* final state is rendered statically.
*/
export type AgentCapsuleState = "slot" | "configured" | "online";
export type AgentCapsuleSizePreset = "sm" | "md" | "lg";
/** Online-pulse colour. `green` for app-wide reuse; `blue` for wizard step 5. */
export type AgentCapsuleGlow = "green" | "blue";
/** Number of brand agent-gradient token pairs defined in index.css. */
export const AGENT_GRADIENT_COUNT = 10;
const SIZE_PRESETS: Record<AgentCapsuleSizePreset, { width: number; height: number }> = {
sm: { width: 24, height: 60 },
md: { width: 34, height: 84 },
lg: { width: 46, height: 116 },
};
const STATE_ARIA: Record<AgentCapsuleState, string> = {
slot: "empty agent slot",
configured: "agent configured, offline",
online: "agent online",
};
export interface AgentCapsuleProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
/** Lifecycle state of the agent the capsule represents. */
state: AgentCapsuleState;
/** Brand agent-gradient index (1–{@link AGENT_GRADIENT_COUNT}). Wraps if out of range. */
gradient?: number;
/** Size preset, or an explicit `{ width, height }` in pixels (keep height ≥ 2× width). */
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);
return ((((n - 1) % AGENT_GRADIENT_COUNT) + AGENT_GRADIENT_COUNT) % AGENT_GRADIENT_COUNT) + 1;
}
export function AgentCapsule({
state,
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))`;
return (
<div
role="img"
aria-label={ariaLabel ?? STATE_ARIA[state]}
data-state={state}
data-gradient={idx}
data-glow={glow}
className={cn(
"agent-cap relative isolate mx-auto overflow-hidden rounded-full bg-transparent",
state === "online" && (glow === "blue" ? "agent-cap-online-blue" : "agent-cap-online"),
className,
)}
style={{ width: dims.width, height: dims.height, ...style }}
{...rest}
>
{/* Dashed outline — an empty agent slot. Visible (and pulsing) only in
the slot state; cross-fades out as the capsule is configured. */}
<span
aria-hidden="true"
className={cn(
"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. Default: cross-fades in
on top of the dashed layer, then out as the liquid rises. strokeDraw:
an SVG outline traced around the perimeter (pathLength 0→1). */}
{strokeDraw ? (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
width="100%"
height="100%"
viewBox={`0 0 ${dims.width} ${dims.height}`}
fill="none"
>
<motion.rect
x={1}
y={1}
width={dims.width - 2}
height={dims.height - 2}
rx={(dims.width - 2) / 2}
strokeWidth={2}
className="stroke-foreground/70"
initial={false}
animate={{ pathLength: drawn ? 1 : 0 }}
transition={
reducedMotion
? { duration: 0 }
: { duration: STROKE_DRAW_SECONDS, ease: STROKE_DRAW_EASE }
}
/>
</svg>
) : (
<span
aria-hidden="true"
className={cn(
"agent-cap-stroke agent-cap-layer pointer-events-none absolute inset-0 rounded-full border-2 border-solid border-foreground/70",
state === "configured" ? "opacity-100" : "opacity-0",
)}
/>
)}
{/* Brand-gradient liquid — rises to fill the capsule when online. */}
{state === "online" ? (
<span
aria-hidden="true"
className="agent-cap-liquid absolute inset-x-0 bottom-0 block h-full"
style={{ background: fill }}
/>
) : null}
</div>
);
}
export default AgentCapsule;