From 527e146980ad0e868aaea067122ef461fe98a2cc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:46:14 -0500 Subject: [PATCH 01/14] feat(ui): share animated agent setup prompts (#14862) Use the shared animated prompt-copy control across setup, invitations, webhooks, and task handoffs. Preserve first-click copying, clipboard recovery, and logo continuity. Add Storybook coverage and restore mention icon masks for the current Lucide data shape. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 14 + ui/src/components/AgentSetupPrompt.tsx | 281 ++++++++++++++++++ .../IssueContinuationHandoff.test.tsx | 3 +- .../components/IssueContinuationHandoff.tsx | 42 +-- ui/src/components/NewAgentDialog.test.tsx | 51 +++- .../new-agent/ExternalAgentInviteDialog.tsx | 38 ++- .../routine-triggers/WebhookFields.tsx | 44 +-- ui/src/index.css | 76 +++++ ui/src/lib/mention-chips.ts | 7 +- ui/src/pages/DesignGuide.tsx | 2 +- .../apps/chat/GitHubSetupPrompt.test.tsx | 36 ++- ui/src/pages/apps/chat/GitHubSetupPrompt.tsx | 2 +- ui/src/pages/apps/chat/SetupPrompt.tsx | 41 +-- .../pages/apps/chat/SlackSetupPrompt.test.tsx | 80 ++++- ui/src/pages/apps/chat/SlackSetupPrompt.tsx | 2 +- .../pages/tools/McpConfigHelpDialog.test.tsx | 14 +- ui/src/pages/tools/McpConfigHelpDialog.tsx | 55 +--- .../stories/agent-setup-prompt.stories.tsx | 194 ++++++++++++ .../stories/slack-setup-prompt.stories.tsx | 2 +- 19 files changed, 775 insertions(+), 209 deletions(-) create mode 100644 ui/src/components/AgentSetupPrompt.tsx create mode 100644 ui/storybook/stories/agent-setup-prompt.stories.tsx diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 5d37d441c8b..983f2a4ca67 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -100,6 +100,20 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +Use **Components → Agent setup prompt** to review the shared setup handoff: +hover/focus logo motion, one-click copying with a prompt preview, animated +confirmation, and manual-copy recovery. Opening the preview copies immediately; +clicking its trigger again copies without closing. Close and Escape only dismiss; +the inner copy button remains available for retries and later copies. Stories include Slack and API copy, a compact quick-access placement, +light and mobile views, and a clipboard paste check. `AgentSetupPrompt` accepts +the complete `prompt`, `title`, `description`, trigger `label`, and popover +placement. Use it for prompts handed to an external agent: Slack and GitHub +connections, MCP configuration help, routine webhook setup, external-agent +invitations, and task continuation. The **App placements** story collects the +production controls; **Inside MCP help** exercises the popover inside a dialog. +Prompt generation can pass `initialCopyStatus` to preserve its automatic copy +result, and `onCopied` to clear an earlier generation-time clipboard error. + Use **Design explorations → Agent chat sidebar** to review the production secondary agent navigation. **Components** covers selection, search, loading, empty results, and larger teams. **Pages** puts it beside the production agent chat inside the diff --git a/ui/src/components/AgentSetupPrompt.tsx b/ui/src/components/AgentSetupPrompt.tsx new file mode 100644 index 00000000000..df025d7eb3a --- /dev/null +++ b/ui/src/components/AgentSetupPrompt.tsx @@ -0,0 +1,281 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type Ref } from "react"; +import { createPortal } from "react-dom"; +import { Check, Copy, Terminal, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { motionMilliseconds, motionNumber } from "@/lib/onboarding-motion-tokens"; +import { cn } from "@/lib/utils"; + +export interface AgentSetupPromptProps { + /** The complete text to preview and copy; never truncated on the clipboard. */ + prompt: string; + title?: string; + description?: string; + label?: string; + variant?: "outline" | "ghost"; + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + className?: string; + /** Carry over the clipboard result when generating a prompt already copied it. */ + initialCopyStatus?: "idle" | "copied" | "failed"; + onCopied?: () => void; +} + +const agents = [ + { name: "Codex", src: "/brands/codex-color.svg", className: "" }, + { name: "ChatGPT", src: "/brands/apps/openai.svg", className: "dark:invert" }, + { name: "Claude", src: "/brands/claude-color.svg", className: "" }, +]; + +function AgentLogos({ ref, hidden }: { ref: Ref; hidden: boolean }) { + return ( + + ); +} + +function readLogoPositions(group: HTMLSpanElement | null) { + return Array.from(group?.querySelectorAll(".agent-setup-logo") ?? []).map((logo) => { + const bounds = logo.getBoundingClientRect(); + const transform = getComputedStyle(logo).transform; + const matrix = transform && transform !== "none" ? new DOMMatrixReadOnly(transform) : null; + return { + // A rotated bounding box is larger than the logo. Anchor its unscaled + // box at the same center, preserving the current hover angle separately. + left: bounds.left + (bounds.width - logo.offsetWidth) / 2, + top: bounds.top + (bounds.height - logo.offsetHeight) / 2, + width: logo.offsetWidth, + height: logo.offsetHeight, + transform: `rotate(${matrix ? Math.atan2(matrix.b, matrix.a) * 180 / Math.PI : 0}deg)`, + }; + }); +} + +/** A small handoff to the user's agent, with an inspectable prompt and local copy feedback. */ +export function AgentSetupPrompt({ + prompt, + title = "Agent setup", + description = "Paste this into your agent to take care of setup.", + label = "Set up with an agent", + variant = "outline", + side = "top", + align = "start", + className, + initialCopyStatus = "idle", + onCopied, +}: AgentSetupPromptProps) { + const id = useId(); + const reducedMotion = usePrefersReducedMotion(); + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<"idle" | "copying" | "copied" | "failed">(initialCopyStatus); + const [flight, setFlight] = useState<{ origins: ReturnType; toPreview: boolean } | null>(null); + const triggerLogos = useRef(null); + const previewLogos = useRef(null); + const flyingLogos = useRef(null); + const activationOrigins = useRef | null>(null); + const copyAttempt = useRef(0); + const hasUserCopyAttempt = useRef(false); + const copying = useRef(false); + const copyButton = useRef(null); + + useEffect(() => { + setStatus("idle"); + copying.current = false; + hasUserCopyAttempt.current = false; + // A pending clipboard result must not label a changed prompt as copied. + return () => { copyAttempt.current += 1; }; + }, [prompt]); + + useEffect(() => { + // A delayed generation-time copy must not replace the user's newer result. + if (!hasUserCopyAttempt.current) setStatus(initialCopyStatus); + }, [prompt, initialCopyStatus]); + + useEffect(() => { + if (status !== "copied") return; + const hold = motionMilliseconds("--agent-setup-copied-hold"); + if (hold <= 0) return; + const timeout = window.setTimeout(() => setStatus("idle"), hold); + return () => window.clearTimeout(timeout); + }, [status]); + + useLayoutEffect(() => { + if (!flight) return; + if (reducedMotion || typeof Element.prototype.animate !== "function") { + setFlight(null); + return; + } + let frame: number; + let cancelled = false; + let animations: Animation[] = []; + const finish = () => { if (!cancelled) setFlight(null); }; + const start = () => { + const destination = flight.toPreview ? previewLogos.current : triggerLogos.current; + // Radix publishes this measurement only after positioning its portal. + // Until then the full-size flight logos remain at the captured origin. + if (!destination || (flight.toPreview && !getComputedStyle(destination).getPropertyValue("--radix-popover-content-transform-origin").trim())) { + frame = requestAnimationFrame(start); + return; + } + const targets = readLogoPositions(destination); + const logos = flyingLogos.current?.querySelectorAll(".agent-setup-logo"); + const easing = getComputedStyle(document.documentElement).getPropertyValue("--motion-ease-out-expo").trim(); + animations = Array.from(logos ?? []).map((logo, index) => logo.animate( + [flight.origins[index], targets[index]].map(({ left, top, transform }) => ({ left: `${left}px`, top: `${top}px`, transform })), + { duration: motionMilliseconds("--agent-setup-flight-duration"), easing, fill: "both" }, + )); + void Promise.all(animations.map((animation) => animation.finished)).then(finish, () => {}); + }; + frame = requestAnimationFrame(start); + // If the viewport moves mid-flight, settle at its live destination. + window.addEventListener("resize", finish); + document.addEventListener("scroll", finish, true); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + animations.forEach((animation) => animation.cancel()); + window.removeEventListener("resize", finish); + document.removeEventListener("scroll", finish, true); + }; + }, [flight, reducedMotion]); + + function changeOpen(nextOpen: boolean) { + const origins = activationOrigins.current ?? readLogoPositions(flyingLogos.current ?? (open ? previewLogos.current : triggerLogos.current)); + activationOrigins.current = null; + setFlight(!reducedMotion && origins.length === agents.length ? { origins, toPreview: nextOpen } : null); + setOpen(nextOpen); + } + + async function copyPrompt() { + if (copying.current || !prompt.trim()) return; + hasUserCopyAttempt.current = true; + copying.current = true; + const attempt = ++copyAttempt.current; + setStatus("copying"); + try { + await copyTextToClipboard(prompt); + if (attempt === copyAttempt.current) { + setStatus("copied"); + onCopied?.(); + } + } catch { + if (attempt === copyAttempt.current) setStatus("failed"); + } finally { + if (attempt === copyAttempt.current) copying.current = false; + } + } + + return ( + <> + + + + + { + event.preventDefault(); + copyButton.current?.focus(); + }} + onFocusOutside={(event) => { + // The legacy clipboard fallback briefly focuses a textarea in + // document.body. Keep the preview open so its result stays visible. + if (copying.current) event.preventDefault(); + }} + > +
+
+

{title}

+

{description}

+
+ +
+ +
+
+ {status === "failed" ? ( +