mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 20:09:05 +02:00
## 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>
188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
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;
|