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 <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-10-01 11:46:14 -05:00
committed by GitHub
co-authored by Paperclip
parent 6395cae072
commit 527e146980
19 changed files with 775 additions and 209 deletions
+14
View File
@@ -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
+281
View File
@@ -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<HTMLSpanElement>; hidden: boolean }) {
return (
<span ref={ref} className="agent-setup-logos" data-hidden={hidden} aria-hidden="true">
{agents.map((agent) => (
<span key={agent.name} className="agent-setup-logo-slot">
<span className="agent-setup-logo">
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
</span>
</span>
))}
</span>
);
}
function readLogoPositions(group: HTMLSpanElement | null) {
return Array.from(group?.querySelectorAll<HTMLElement>(".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<typeof readLogoPositions>; toPreview: boolean } | null>(null);
const triggerLogos = useRef<HTMLSpanElement>(null);
const previewLogos = useRef<HTMLSpanElement>(null);
const flyingLogos = useRef<HTMLSpanElement>(null);
const activationOrigins = useRef<ReturnType<typeof readLogoPositions> | null>(null);
const copyAttempt = useRef(0);
const hasUserCopyAttempt = useRef(false);
const copying = useRef(false);
const copyButton = useRef<HTMLButtonElement>(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<HTMLElement>(".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 (
<>
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
aria-label={label}
data-copied={status === "copied"}
onClick={(event) => {
// The trigger remains a copy action while open. Dismiss with
// Close, Escape, or outside interaction without writing again.
if (open) event.preventDefault();
else activationOrigins.current = readLogoPositions(flyingLogos.current ?? triggerLogos.current);
void copyPrompt();
}}
className={cn("agent-setup-trigger h-auto min-h-10 max-w-full gap-3 whitespace-normal text-left", variant === "ghost" && "w-full justify-start", className)}
>
{status === "copied" ? <Check className="agent-setup-check size-4" /> : <Terminal className="size-4 text-muted-foreground" />}
<span className="relative min-w-0">
<span className={status === "copied" ? "invisible" : undefined}>{label}</span>
{status === "copied" && <span className="agent-setup-confirmation absolute inset-0 flex items-center">Copied!</span>}
</span>
<span className="agent-setup-trigger-logos"><AgentLogos ref={triggerLogos} hidden={open || Boolean(flight)} /></span>
</Button>
</PopoverTrigger>
<PopoverContent
side={side}
align={align}
collisionPadding={motionNumber("--agent-setup-collision-padding")}
className="agent-setup-popover flex flex-col gap-4 rounded-xl p-4"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`}
onOpenAutoFocus={(event) => {
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();
}}
>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h2 id={`${id}-title`} className="text-sm font-semibold">{title}</h2>
<p id={`${id}-description`} className="text-sm leading-relaxed text-muted-foreground">{description}</p>
</div>
<Button type="button" variant="ghost" size="icon-xs" aria-label="Close agent setup" onClick={() => changeOpen(false)}>
<X className="size-3.5" />
</Button>
</div>
<div className="agent-setup-preview-wrap relative pt-2">
<div className="agent-setup-preview-logos absolute right-3 top-0 z-10"><AgentLogos ref={previewLogos} hidden={Boolean(flight)} /></div>
{status === "failed" ? (
<Textarea
aria-label="Setup prompt"
readOnly
value={prompt}
onFocus={(event) => event.currentTarget.select()}
className="agent-setup-preview resize-none rounded-lg bg-muted/50 p-3 pt-5 font-mono text-xs leading-relaxed"
/>
) : (
<pre tabIndex={0} aria-label="Setup prompt" className="agent-setup-preview m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border/60 bg-muted/50 p-3 pt-5 font-mono text-xs leading-relaxed text-muted-foreground">{prompt}</pre>
)}
</div>
<div className="flex flex-col gap-2">
{status === "failed" && (
<p role="alert" className="text-xs text-destructive">Could not copy automatically. Select and copy the prompt above, or try again.</p>
)}
<Button
ref={copyButton}
type="button"
variant="cta"
className="agent-setup-copy w-full"
data-copied={status === "copied"}
aria-disabled={status === "copying" || !prompt.trim()}
onClick={() => { if (prompt.trim()) void copyPrompt(); }}
>
{status === "copied" ? <Check className="agent-setup-check size-4" /> : <Copy className="size-4" />}
{status === "copied" ? "Copied to clipboard" : status === "copying" ? "Copying…" : status === "failed" ? "Try copying again" : "Copy prompt"}
</Button>
<p className="text-center text-xs text-muted-foreground">
{status === "copied" ? "Ready to paste into your agent." : "Works with Codex, ChatGPT, Claude, and more."}
</p>
</div>
</PopoverContent>
</Popover>
<span role="status" className="sr-only">{status === "copied" ? "Setup prompt copied. Ready to paste into your agent." : ""}</span>
{flight && createPortal(
<span ref={flyingLogos} className="agent-setup-flight-layer" aria-hidden="true">
{agents.map((agent, index) => (
<span key={agent.name} className="agent-setup-logo agent-setup-flying-logo" style={flight.origins[index]}>
<img src={agent.src} alt="" className={cn("size-5", agent.className)} draggable={false} />
</span>
))}
</span>,
document.body,
)}
</>
);
}
@@ -75,7 +75,7 @@ describe("IssueContinuationHandoff", () => {
expect(container.textContent).not.toContain("Resume from the activity tab.");
const copyButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Copy"));
.find((button) => button.getAttribute("aria-label") === "Continue with an agent");
expect(copyButton).toBeTruthy();
await act(async () => {
@@ -84,6 +84,7 @@ describe("IssueContinuationHandoff", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(handoff.body);
expect(container.textContent).toContain("Copied");
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(handoff.body);
await act(async () => {
root.unmount();
+10 -32
View File
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { IssueDocument } from "@paperclipai/shared";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
import { ChevronDown, ChevronRight, History } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { copyTextToClipboard } from "@/lib/clipboard";
type IssueContinuationHandoffProps = {
document: IssueDocument | null | undefined;
@@ -20,18 +19,8 @@ export function IssueContinuationHandoff({
externalReferences,
}: IssueContinuationHandoffProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [highlighted, setHighlighted] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
};
}, []);
useEffect(() => {
if (!document || focusSignal <= 0) return;
@@ -42,20 +31,6 @@ export function IssueContinuationHandoff({
return () => clearTimeout(timer);
}, [document, focusSignal]);
const copyBody = useCallback(async () => {
if (!document) return;
try {
await copyTextToClipboard(document.body);
} catch {
return;
}
setCopied(true);
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);
}
copiedTimerRef.current = setTimeout(() => setCopied(false), 1500);
}, [document]);
if (!document) return null;
const title = document.title?.trim() || "Continuation handoff";
@@ -92,10 +67,13 @@ export function IssueContinuationHandoff({
{document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""}
</div>
</div>
<Button variant="ghost" size="sm" onClick={copyBody} className="shrink-0">
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
<AgentSetupPrompt
prompt={document.body}
label="Continue with an agent"
title="Task handoff"
description="Paste this into your agent to continue from this handoff."
align="end"
/>
</div>
{expanded ? (
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
+50 -1
View File
@@ -184,7 +184,12 @@ it("keeps agent-only invitations reachable from the new-agent flow", async () =>
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
});
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
expect(invites.copy).toHaveBeenCalled();
expect(invites.copy).toHaveBeenCalledTimes(1);
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Copy onboarding prompt"]')!.click());
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(invites.copy.mock.calls[0][0]);
expect(state.navigate).not.toHaveBeenCalled();
});
@@ -197,3 +202,47 @@ it("keeps the generated invitation readable when clipboard access fails", async
expect(document.body.textContent).toContain("Copy the prompt manually");
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
});
it("clears the invitation copy failure after retrying without generating another invite", async () => {
invites.copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(document.body.textContent).toContain("Clipboard unavailable");
await click("Copy onboarding prompt");
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
expect(document.body.textContent).not.toContain("Clipboard unavailable");
expect(document.body.textContent).toContain("Copied to clipboard");
});
it("shows the generated invitation while automatic clipboard access is pending", async () => {
let finishCopy!: () => void;
invites.copy.mockImplementationOnce(() => new Promise<void>((resolve) => { finishCopy = resolve; }));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("false");
await act(async () => finishCopy());
expect(document.querySelector('[aria-label="Copy onboarding prompt"]')?.getAttribute("data-copied")).toBe("true");
});
it("keeps a newer user copy result when the automatic invitation copy fails", async () => {
let failAutomaticCopy!: (error: Error) => void;
let finishUserCopy!: () => void;
invites.copy
.mockImplementationOnce(() => new Promise<void>((_resolve, reject) => { failAutomaticCopy = reject; }))
.mockImplementationOnce(() => new Promise<void>((resolve) => { finishUserCopy = resolve; }));
await click("Invite an external agent");
await click("Generate onboarding prompt");
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
await click("Copy onboarding prompt");
await act(async () => failAutomaticCopy(new Error("Automatic copy failed")));
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copying…");
await act(async () => finishUserCopy());
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(document.body.textContent).not.toContain("Clipboard unavailable");
expect(invites.copy).toHaveBeenCalledTimes(2);
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
});
@@ -4,6 +4,7 @@ import { accessApi } from "@/api/access";
import { queryKeys } from "@/lib/queryKeys";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
@@ -21,17 +22,8 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
return () => { mounted.current = false; };
}, []);
const [message, setMessage] = useState("");
const [prompt, setPrompt] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
async function copy(value: string) {
try {
await copyTextToClipboard(value);
if (mounted.current) { setCopied(true); setCopyError(false); }
} catch {
if (mounted.current) setCopyError(true);
}
}
const [result, setResult] = useState<{ prompt: string; copyStatus: "idle" | "copied" | "failed" } | null>(null);
const prompt = result?.prompt;
const createInvite = useMutation({
mutationFn: async () => {
const invite = await accessApi.createCompanyInvite(companyId, {
@@ -51,8 +43,15 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
},
onSuccess: async (value) => {
if (!mounted.current) return;
setPrompt(value);
await copy(value);
// Keep the invitation readable while a browser clipboard request is pending.
setResult({ prompt: value, copyStatus: "idle" });
let copyStatus: "copied" | "failed" = "copied";
try {
await copyTextToClipboard(value);
} catch {
copyStatus = "failed";
}
if (mounted.current) setResult((current) => current?.copyStatus === "idle" ? { ...current, copyStatus } : current);
},
});
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
@@ -64,10 +63,17 @@ export function ExternalAgentInviteDialog({ companyId, onClose, onBack }: {
</DialogDescription>
{prompt ? <>
<Textarea aria-label="Agent onboarding prompt" readOnly value={prompt} className="min-h-64 font-mono text-xs" />
{copyError && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above.</p>}
<div className="flex justify-between gap-4">
{result?.copyStatus === "failed" && <p role="alert" className="text-sm text-muted-foreground">Clipboard unavailable. Copy the prompt manually from the field above, or use the button below to try again.</p>}
<div className="flex flex-wrap items-center justify-between gap-4">
<Button variant="ghost" onClick={onClose}>Done</Button>
<Button variant="outline" onClick={() => void copy(prompt)}>{copied ? "Copied prompt" : "Copy prompt"}</Button>
<AgentSetupPrompt
prompt={prompt}
label="Copy onboarding prompt"
title="Invite your agent"
description="Paste this into your external agent to request access to your organization."
initialCopyStatus={result?.copyStatus}
onCopied={() => setResult((current) => current && { ...current, copyStatus: "copied" })}
/>
</div>
</> : <>
<label className="space-y-2 text-sm">
@@ -3,6 +3,7 @@ import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
export function CopyField({
label,
@@ -64,8 +65,6 @@ export function CopyField({
}
export function AgentInstructions({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
return (
<section
aria-label="Agent instructions"
@@ -78,41 +77,12 @@ export function AgentInstructions({ value }: { value: string }) {
authentication key, and step-by-step instructions.
</p>
</div>
<Button
variant="outline"
size="sm"
aria-label="Copy for your agent"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
setError(false);
} catch {
setError(true);
}
}}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied instructions" : "Copy for your agent"}
</Button>
{error && (
<div className="space-y-2">
<p role="alert" className="text-xs text-destructive">
Copy failed. Select and copy the instructions below.
</p>
<textarea
readOnly
aria-label="Agent instructions text"
value={value}
rows={5}
className="w-full rounded-md border border-input bg-background p-3 text-sm"
/>
</div>
)}
<AgentSetupPrompt
prompt={value}
label="Copy for your agent"
title="Webhook setup"
description="Paste this into your agent to connect this webhook to your routine."
/>
</section>
);
}
+76
View File
@@ -3217,3 +3217,79 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.skill-import-enter, .skill-import-sweep { animation: none; }
.skill-import-progress::-webkit-progress-value { transition: none; }
}
/* Agent setup handoff: the same logo stack travels from trigger to prompt. */
:root {
--agent-setup-flight-duration: var(--motion-duration-deliberate);
--agent-setup-copied-hold: 2400ms;
--agent-setup-popover-width: 21rem;
--agent-setup-collision-padding: 12;
--agent-setup-preview-height: clamp(6rem, 27dvh, 12rem);
--agent-setup-logo-size: calc(var(--spacing) * 7);
--agent-setup-logo-overlap: calc(var(--spacing) * -1.5);
--agent-setup-logo-lift: calc(var(--spacing) * -1.5);
--agent-setup-logo-tilt: 14deg;
}
.agent-setup-trigger-logos {
display: inline-flex;
flex-shrink: 0;
width: calc(var(--agent-setup-logo-size) * 3 + var(--agent-setup-logo-overlap) * 2);
height: var(--agent-setup-logo-size);
margin-left: auto;
}
.agent-setup-logos { display: inline-flex; isolation: isolate; }
.agent-setup-logos[data-hidden="true"] { visibility: hidden; }
.agent-setup-logo-slot { display: inline-flex; position: relative; }
.agent-setup-logo-slot + .agent-setup-logo-slot { margin-left: var(--agent-setup-logo-overlap); }
.agent-setup-flight-layer { position: fixed; inset: 0; z-index: 51; pointer-events: none; }
.agent-setup-flight-layer .agent-setup-flying-logo { position: absolute; transition: none; }
.agent-setup-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--agent-setup-logo-size);
height: var(--agent-setup-logo-size);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--popover);
box-shadow: var(--shadow-xs);
transition: transform var(--motion-duration-slow) var(--motion-ease-out-expo);
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(1) .agent-setup-logo {
transform: translateY(var(--agent-setup-logo-lift)) rotate(calc(-1 * var(--agent-setup-logo-tilt)));
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(2) .agent-setup-logo {
transform: translateY(calc(var(--agent-setup-logo-lift) * 1.6));
}
.agent-setup-trigger:is(:hover, :focus-visible) .agent-setup-logo-slot:nth-child(3) .agent-setup-logo {
transform: translateY(calc(var(--agent-setup-logo-lift) * 0.6)) rotate(var(--agent-setup-logo-tilt));
}
.agent-setup-popover {
width: min(var(--agent-setup-popover-width), calc(100vw - var(--spacing) * 6));
max-height: var(--radix-popover-content-available-height);
box-shadow: var(--shadow-profile-popover);
}
.agent-setup-popover[data-state="open"] { animation: tc-fade-in var(--motion-duration-base) var(--motion-ease-out-expo); }
.agent-setup-popover[data-state="closed"] { animation: none; }
.agent-setup-preview {
height: var(--agent-setup-preview-height);
max-height: max(3rem, calc(var(--radix-popover-content-available-height) - 12rem));
scrollbar-width: thin;
}
.agent-setup-preview-logos { transform: translateY(-25%); }
.agent-setup-preview-logos .agent-setup-logo-slot:first-child .agent-setup-logo { transform: rotate(calc(var(--agent-setup-logo-tilt) * -0.5)); }
.agent-setup-preview-logos .agent-setup-logo-slot:last-child .agent-setup-logo { transform: rotate(calc(var(--agent-setup-logo-tilt) * 0.5)); }
.agent-setup-check { animation: agent-setup-check var(--motion-duration-slow) var(--motion-ease-out-expo) both; }
.agent-setup-confirmation { animation: tc-fade-rise var(--motion-duration-base) var(--motion-ease-out-expo) both; }
.agent-setup-copy[aria-disabled="true"] { opacity: 0.5; }
@keyframes agent-setup-check {
from { opacity: 0; transform: scale(0.6) rotate(-20deg); }
to { opacity: 1; transform: scale(1) rotate(0); }
}
@media (prefers-reduced-motion: reduce) {
.agent-setup-logo { transition: none; transform: none !important; }
.agent-setup-popover, .agent-setup-check, .agent-setup-confirmation { animation: none !important; }
}
@media (max-height: 420px) {
.agent-setup-popover { overflow-y: auto; }
}
+5 -2
View File
@@ -212,12 +212,15 @@ function resolveLucideIconNode(
const render = (
icon as {
render?: (props: Record<string, unknown>, ref: unknown) => {
props?: { iconNode?: Array<[string, Record<string, string>]> };
props?: {
iconNode?: Array<[string, Record<string, string>]>;
icon?: { node?: Array<[string, Record<string, string>]> };
};
} | null;
}
).render;
const rendered = typeof render === "function" ? render({}, null) : null;
const renderedIconNode = rendered?.props?.iconNode;
const renderedIconNode = rendered?.props?.icon?.node ?? rendered?.props?.iconNode;
return Array.isArray(renderedIconNode) && renderedIconNode.length > 0
? renderedIconNode
: null;
+1 -1
View File
@@ -2286,7 +2286,7 @@ export function DesignGuide() {
</Section>
<Section title="Browser setup prompt">
<p className="text-sm text-muted-foreground">A shared copy action for provider setup instructions. Confirms success inline and offers selectable text if clipboard access fails.</p>
<p className="text-sm text-muted-foreground">Use AgentSetupPrompt for prompts handed to an external agent: connections, webhook setup, onboarding, and task handoffs. One click copies the complete prompt, opens its preview, and confirms success inline; clipboard failures offer selectable text.</p>
<SetupPrompt prompt="Design guide example. This is a preview, not a real provider setup request." />
</Section>
@@ -24,36 +24,48 @@ describe("GitHub setup prompt", () => {
container.remove();
});
it("copies the complete instructions and confirms success", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
async function openPrompt() {
await act(async () => container.querySelector("button")!.click());
return document.querySelector('[role="dialog"]')!;
}
async function copyPrompt(dialog: Element) {
await act(async () => dialog.querySelector<HTMLButtonElement>(".agent-setup-copy")!.click());
}
it("copies on the opening click and confirms success in both buttons", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildGitHubSetupPrompt(window.location.origin));
expect(vi.mocked(copyTextToClipboard).mock.calls[0][0]).toContain(`Paperclip instance URL: ${window.location.origin}`);
expect(container.querySelector("button")?.textContent).toBe("Copied setup prompt");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Paste it into Codex or Claude");
expect(container.querySelector("textarea")).toBeNull();
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Ready to paste into your agent.");
expect(container.querySelector("button")?.dataset.copied).toBe("true");
expect(dialog.querySelector("textarea")).toBeNull();
});
it("offers selectable instructions when clipboard access fails and allows retry", async () => {
vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable"));
await act(async () => container.querySelector("button")!.click());
const fallback = container.querySelector("textarea")!;
const dialog = await openPrompt();
const fallback = dialog.querySelector("textarea")!;
expect(fallback.value).toBe(buildGitHubSetupPrompt(window.location.origin));
expect(fallback.readOnly).toBe(true);
fallback.focus();
expect(fallback.selectionStart).toBe(0);
expect(fallback.selectionEnd).toBe(fallback.value.length);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
vi.mocked(copyTextToClipboard).mockResolvedValueOnce(undefined);
await act(async () => container.querySelector("button")!.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
await copyPrompt(dialog);
expect(dialog.querySelector('[role="alert"]')).toBeNull();
expect(dialog.querySelector("textarea")).toBeNull();
});
it("uses the preview's configured instance instead of its Storybook host", async () => {
await act(async () => root.render(<GitHubSetupPrompt instanceUrl="https://my-company.paperclip.app" />));
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
await act(async () => container.querySelector("button")!.click());
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildGitHubSetupPrompt("https://my-company.paperclip.app"));
});
+1 -1
View File
@@ -43,5 +43,5 @@ export function buildGitHubSetupPrompt(instanceUrl: string) {
}
export function GitHubSetupPrompt({ instanceUrl = window.location.origin }: { instanceUrl?: string }) {
return <SetupPrompt prompt={buildGitHubSetupPrompt(instanceUrl)} />;
return <SetupPrompt prompt={buildGitHubSetupPrompt(instanceUrl)} description="Paste this into your agent to configure your GitHub review bot." />;
}
+1 -40
View File
@@ -1,8 +1,4 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard } from "@/lib/clipboard";
export { AgentSetupPrompt as SetupPrompt } from "@/components/AgentSetupPrompt";
export function buildSetupPrompt(instanceUrl: string, instructions: string) {
let instanceOrigin: string | null = null;
@@ -17,38 +13,3 @@ export function buildSetupPrompt(instanceUrl: string, instructions: string) {
: "Paperclip instance URL is unavailable. Ask me for it before starting setup.";
return `${context}\n\n${instructions}`;
}
export function SetupPrompt({ prompt }: { prompt: string }) {
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
return (
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="gap-2 border-dashed text-muted-foreground"
onClick={async () => {
try {
await copyTextToClipboard(prompt);
setStatus("copied");
} catch {
setStatus("failed");
}
}}
>
<span className="flex -space-x-1" aria-hidden="true">
<img src="/brands/claude-color.svg" alt="" className="size-4 rounded-full bg-background ring-2 ring-background" />
<img src="/brands/codex-color.svg" alt="" className="size-4 rounded-full bg-background ring-2 ring-background" />
</span>
{status === "copied" ? "Copied setup prompt" : "Copy setup prompt"}
{status === "copied" ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</Button>
<span className="sr-only" role="status">{status === "copied" ? "Setup prompt copied. Paste it into Codex or Claude with browser tools." : ""}</span>
{status === "failed" && (
<div className="space-y-2">
<p role="alert" className="text-sm text-muted-foreground">Could not copy automatically. Select and copy the setup prompt below.</p>
<Textarea aria-label="Setup prompt" readOnly value={prompt} onFocus={(event) => event.currentTarget.select()} rows={8} />
</div>
)}
</div>
);
}
@@ -24,39 +24,93 @@ describe("Slack setup prompt", () => {
container.remove();
});
it("copies the complete instructions and confirms success", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
async function openPrompt() {
await act(async () => container.querySelector("button")!.click());
return document.querySelector('[role="dialog"]')!;
}
async function copyPrompt(dialog: Element) {
await act(async () => dialog.querySelector<HTMLButtonElement>(".agent-setup-copy")!.click());
}
it("copies on the opening click and confirms success in both buttons", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildSlackSetupPrompt(window.location.origin));
expect(vi.mocked(copyTextToClipboard).mock.calls[0][0]).toContain(`Paperclip instance URL: ${window.location.origin}`);
expect(container.querySelector("button")?.textContent).toBe("Copied setup prompt");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Paste it into Codex or Claude");
expect(container.querySelector("textarea")).toBeNull();
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Ready to paste into your agent.");
expect(container.querySelector("button")?.dataset.copied).toBe("true");
expect(dialog.querySelector("textarea")).toBeNull();
});
it("offers selectable instructions when clipboard access fails and allows retry", async () => {
vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable"));
await act(async () => container.querySelector("button")!.click());
const fallback = container.querySelector("textarea")!;
vi.mocked(copyTextToClipboard).mockImplementationOnce(async () => {
// The browser fallback focuses an off-screen textarea outside the popover.
const clipboardField = document.createElement("textarea");
document.body.append(clipboardField);
clipboardField.focus();
clipboardField.remove();
throw new Error("Clipboard unavailable");
});
const dialog = await openPrompt();
const fallback = dialog.querySelector("textarea")!;
expect(fallback.value).toBe(buildSlackSetupPrompt(window.location.origin));
expect(fallback.readOnly).toBe(true);
fallback.focus();
expect(fallback.selectionStart).toBe(0);
expect(fallback.selectionEnd).toBe(fallback.value.length);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
expect(dialog.querySelector('[role="alert"]')?.textContent).toContain("Could not copy");
vi.mocked(copyTextToClipboard).mockResolvedValueOnce(undefined);
await act(async () => container.querySelector("button")!.click());
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector("textarea")).toBeNull();
await copyPrompt(dialog);
expect(dialog.querySelector('[role="alert"]')).toBeNull();
expect(dialog.querySelector("textarea")).toBeNull();
});
it("uses the preview's configured instance instead of its Storybook host", async () => {
await act(async () => root.render(<SlackSetupPrompt instanceUrl="https://my-company.paperclip.app" />));
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
await act(async () => container.querySelector("button")!.click());
const dialog = await openPrompt();
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(copyTextToClipboard).toHaveBeenCalledWith(buildSlackSetupPrompt("https://my-company.paperclip.app"));
});
it("ignores stale copy results when the prompt changes", async () => {
let finishCopy!: () => void;
vi.mocked(copyTextToClipboard).mockImplementationOnce(() => new Promise<void>((resolve) => { finishCopy = resolve; }));
const dialog = await openPrompt();
await copyPrompt(dialog);
await copyPrompt(dialog);
expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copying…");
await act(async () => root.render(<SlackSetupPrompt instanceUrl="https://another.paperclip.app" />));
await act(async () => finishCopy());
expect(dialog.querySelector(".agent-setup-copy")?.textContent).toBe("Copy prompt");
expect(dialog.querySelector("pre")?.textContent).toContain("https://another.paperclip.app");
});
it("copies again on every opening", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
await act(async () => dialog.querySelector<HTMLButtonElement>('[aria-label="Close agent setup"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
await act(async () => container.querySelector("button")!.click());
expect(document.querySelector(".agent-setup-copy")?.textContent).toBe("Copied to clipboard");
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
});
it("keeps the preview open when copying again from the trigger and does not copy on dismissal", async () => {
vi.mocked(copyTextToClipboard).mockResolvedValue(undefined);
const dialog = await openPrompt();
await act(async () => container.querySelector("button")!.click());
expect(document.querySelector('[role="dialog"]')).toBe(dialog);
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
await act(async () => dialog.querySelector<HTMLButtonElement>('[aria-label="Close agent setup"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(copyTextToClipboard).toHaveBeenCalledTimes(2);
});
it("includes only the instance origin, without URL credentials or callback state", () => {
const prompt = buildSlackSetupPrompt("https://user:private-password@my-company.paperclip.app/GIT/apps/chat/connect?code=private-code#private-state");
expect(prompt).toContain("Paperclip instance URL: https://my-company.paperclip.app\n");
+1 -1
View File
@@ -38,5 +38,5 @@ export function buildSlackSetupPrompt(instanceUrl: string) {
}
export function SlackSetupPrompt({ instanceUrl = window.location.origin }: { instanceUrl?: string }) {
return <SetupPrompt prompt={buildSlackSetupPrompt(instanceUrl)} />;
return <SetupPrompt prompt={buildSlackSetupPrompt(instanceUrl)} description="Paste this into your agent to configure your Slack bot." />;
}
@@ -126,13 +126,14 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(copyTextToClipboardMock).toHaveBeenCalledWith(MCP_CONFIG_HELP_PROMPT);
expect(document.body.textContent).toContain("Copied to clipboard.");
expect(copyTextToClipboardMock).toHaveBeenCalledExactlyOnceWith(MCP_CONFIG_HELP_PROMPT);
expect(document.querySelector('pre[aria-label="Setup prompt"]')?.textContent).toBe(MCP_CONFIG_HELP_PROMPT);
expect(document.body.textContent).toContain("Copied to clipboard");
});
it("tells the operator to copy by hand when the clipboard is unavailable", async () => {
@@ -141,14 +142,15 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
await vi.waitFor(() => {
expect(document.body.textContent).toContain("select the text above and copy it");
expect(document.body.textContent).toContain("Select and copy the prompt above");
});
expect(document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Setup prompt"]')?.value).toBe(MCP_CONFIG_HELP_PROMPT);
});
it("makes no connection or import request when opened or copied", async () => {
@@ -163,7 +165,7 @@ describe("Paste a config — MCP config help", () => {
await openHelp();
await act(async () => {
buttonWithText("Copy prompt")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonWithText("Get a config with an agent")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
+10 -45
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Check, Copy, HelpCircle } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -10,11 +10,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { copyTextToClipboard } from "@/lib/clipboard";
import { MCP_CONFIG_HELP_INSTRUCTIONS, MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
const COPIED_RESET_MS = 2_000;
/**
* Compact question-mark help beside the Paste-a-config copy (PAP-17087, plan 3A).
*
@@ -24,33 +21,8 @@ const COPIED_RESET_MS = 2_000;
* anything the operator has typed.
*/
export function McpConfigHelpDialog() {
const [open, setOpen] = useState(false);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (resetTimer.current) clearTimeout(resetTimer.current);
}, []);
// Reset the copy affordance each time the dialog opens, so a stale "Copied"
// from a previous visit never reads as confirmation of this one.
useEffect(() => {
if (open) setCopyState("idle");
}, [open]);
const copyPrompt = async () => {
try {
await copyTextToClipboard(MCP_CONFIG_HELP_PROMPT);
setCopyState("copied");
} catch {
setCopyState("failed");
}
if (resetTimer.current) clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopyState("idle"), COPIED_RESET_MS);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
@@ -92,20 +64,13 @@ export function McpConfigHelpDialog() {
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" onClick={() => void copyPrompt()}>
{copyState === "copied" ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
Copy prompt
</Button>
{/* aria-live so a screen reader hears the outcome without moving focus
off the button the operator just pressed. */}
<span aria-live="polite" className="text-xs text-muted-foreground">
{copyState === "copied"
? "Copied to clipboard."
: copyState === "failed"
? "Couldn't copy automatically — select the text above and copy it."
: null}
</span>
<div>
<AgentSetupPrompt
prompt={MCP_CONFIG_HELP_PROMPT}
label="Get a config with an agent"
title="MCP configuration"
description="Paste this into your agent to create an MCP config, then paste its JSON reply back into Paperclip."
/>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,194 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { MCP_CONFIG_HELP_PROMPT } from "@paperclipai/shared";
import { BookOpen, KeyRound } from "lucide-react";
import { AgentSetupPrompt } from "@/components/AgentSetupPrompt";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { buildSlackSetupPrompt, SlackSetupPrompt } from "@/pages/apps/chat/SlackSetupPrompt";
import { GitHubSetupPrompt } from "@/pages/apps/chat/GitHubSetupPrompt";
import { McpConfigHelpDialog } from "@/pages/tools/McpConfigHelpDialog";
import { AgentInstructions } from "@/components/routine-triggers/WebhookFields";
import { IssueContinuationHandoff } from "@/components/IssueContinuationHandoff";
import { webhookAgentInstructions } from "../fixtures/routineTriggerWizard";
import { storybookContinuationHandoff } from "../fixtures/paperclipData";
const slackPrompt = buildSlackSetupPrompt(import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? "");
const apiPrompt = `Help me connect my agent to the Paperclip API.
First, ask me for my Paperclip instance URL if it is not already available. Read its API documentation and identify the company and agent I want to use.
Guide me through creating a dedicated agent API key. Store it securely in my agent's environment; do not paste it into code, logs, or chat.
Configure the base URL and authentication, then verify the connection with a read-only request. Show me what worked and ask before making any changes to my company.`;
const meta = {
title: "Components/Agent setup prompt",
component: AgentSetupPrompt,
parameters: {
layout: "fullscreen",
docs: { description: { component: "A reusable handoff to an agent. Hover or focus to fan out the logos, click once to copy the prompt and open its preview. Logos travel into the preview; clipboard success stays in place. Supports reduced motion, keyboard dismissal, and manual copying when clipboard access fails. The default story copies the complete production Slack setup prompt." } },
},
args: {
prompt: slackPrompt,
title: "Agent setup",
description: "Paste this into your agent to configure your Slack bot.",
label: "Set up with an agent",
side: "top",
align: "start",
variant: "outline",
},
argTypes: {
variant: { control: "select", options: ["outline", "ghost"] },
side: { control: "select", options: ["top", "bottom", "left", "right"] },
align: { control: "select", options: ["start", "center", "end"] },
},
render: (args) => <div className="flex min-h-screen items-center justify-center p-6"><AgentSetupPrompt {...args} /></div>,
} satisfies Meta<typeof AgentSetupPrompt>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
parameters: { layout: "fullscreen" },
render: (args) => (
<div className="mx-auto flex min-h-screen w-full max-w-xl flex-col justify-center gap-8 p-6">
<div className="flex flex-col gap-3">
<span className="text-xs font-medium uppercase tracking-widest text-muted-foreground">A little help with setup</span>
<h1 className="text-3xl font-semibold tracking-tight">Let your agent handle it.</h1>
<p className="max-w-md text-sm leading-relaxed text-muted-foreground">Give your agent the instructions. Get back to what you were building.</p>
</div>
<div className="flex flex-col gap-6 rounded-xl border border-border bg-card p-6">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<img src="/brands/apps/slack.svg" alt="" className="size-6" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Connect Slack</h2>
<p className="text-sm leading-relaxed text-muted-foreground">Bring your Paperclip agent into your workspace.</p>
</div>
</div>
<div><AgentSetupPrompt {...args} /></div>
</div>
<p className="text-xs text-muted-foreground">Hover the logos. Click once to copy and preview.</p>
</div>
),
};
export const ButtonOnly: Story = {};
export const OpenPreview: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Set up with an agent" }));
await expect(within(canvasElement.ownerDocument.body).getByRole("dialog", { name: "Agent setup" })).toBeVisible();
},
};
export const Light: Story = { ...Playground, globals: { theme: "light" } };
export const Mobile: Story = {
...Playground,
globals: { viewport: { value: "mobile1", isRotated: false } },
};
export const ApiSetup: Story = {
args: { prompt: apiPrompt, description: "Paste this into your agent to configure the API.", label: "Configure with an agent" },
};
export const ClipboardCheck: Story = {
render: (args) => (
<div className="mx-auto flex min-h-screen w-full max-w-lg flex-col justify-center gap-4 p-6">
<div><AgentSetupPrompt {...args} /></div>
<label htmlFor="clipboard-check" className="text-sm text-muted-foreground">Click the setup button, then paste here to check the full text.</label>
<Textarea id="clipboard-check" placeholder="Paste the copied prompt here" rows={8} className="font-mono text-xs" />
</div>
),
};
export const QuickAccess: Story = {
args: { prompt: apiPrompt, description: "Paste this into your agent to configure the API.", variant: "ghost", label: "Agent setup" },
render: (args) => (
<div className="flex min-h-screen items-center p-6">
<div className="flex w-64 flex-col gap-1 rounded-xl border border-border bg-card p-2">
<p className="px-3 py-2 text-xs text-muted-foreground">Quick access</p>
<Button variant="ghost" className="justify-start" disabled><KeyRound />API key</Button>
<AgentSetupPrompt {...args} />
<Button variant="ghost" className="justify-start" disabled><BookOpen />Documentation</Button>
</div>
</div>
),
};
export const ClipboardUnavailable: Story = {
beforeEach: () => {
const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const execCommand = Object.getOwnPropertyDescriptor(document, "execCommand");
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async () => { throw new Error("Clipboard disabled for this story"); } } });
Object.defineProperty(document, "execCommand", { configurable: true, value: () => false });
return () => {
if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
else Reflect.deleteProperty(navigator, "clipboard");
if (execCommand) Object.defineProperty(document, "execCommand", execCommand);
else Reflect.deleteProperty(document, "execCommand");
};
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(within(canvasElement).getByRole("button", { name: "Set up with an agent" }));
await expect(body.getByRole("alert")).toHaveTextContent("Could not copy automatically");
await expect(body.getByRole("textbox", { name: "Setup prompt" })).toHaveValue(slackPrompt);
},
};
export const AppPlacements: Story = {
render: () => (
<div className="mx-auto flex min-h-screen w-full max-w-2xl flex-col gap-8 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Hand it to your agent</h1>
<p className="text-sm text-muted-foreground">The same one-click handoff across the app. These are the production controls, with sample data for webhooks and task continuation.</p>
</div>
<section className="space-y-3">
<h2 className="text-sm font-medium">Slack connection</h2>
<SlackSetupPrompt instanceUrl={import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""} />
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">GitHub connection</h2>
<GitHubSetupPrompt instanceUrl={import.meta.env.VITE_PAPERCLIP_INSTANCE_URL ?? ""} />
</section>
<section className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium">MCP configuration help</h2>
<McpConfigHelpDialog />
</div>
</section>
<AgentInstructions value={webhookAgentInstructions("github")} />
<IssueContinuationHandoff document={storybookContinuationHandoff} />
</div>
),
};
export const InsideMcpHelp: Story = {
render: () => <div className="p-6"><McpConfigHelpDialog /></div>,
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
// Synthetic play events lack clipboard user activation in some browsers.
// Restore the real clipboard after the assertions for manual exploration.
const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const writeText = fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Get help creating an MCP config" }));
const trigger = await body.findByRole("button", { name: "Get a config with an agent" });
await userEvent.click(trigger);
await waitFor(async () => {
await expect(body.getByRole("dialog", { name: "MCP configuration" })).toBeVisible();
await expect(body.getByRole("button", { name: "Copied to clipboard" })).toHaveFocus();
await expect(writeText).toHaveBeenCalledTimes(1);
await expect(writeText).toHaveBeenCalledWith(MCP_CONFIG_HELP_PROMPT);
});
await userEvent.keyboard("{Escape}");
await waitFor(async () => {
await expect(body.queryByRole("dialog", { name: "MCP configuration" })).not.toBeInTheDocument();
await expect(body.getByRole("dialog", { name: "Ask an agent for an MCP config" })).toBeVisible();
await expect(trigger).toHaveFocus();
await expect(writeText).toHaveBeenCalledTimes(1);
});
} finally {
if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
else Reflect.deleteProperty(navigator, "clipboard");
}
},
};
@@ -10,7 +10,7 @@ const meta = {
component: SlackSetupPrompt,
parameters: {
layout: "padded",
docs: { description: { component: "Production copy button and full browser-agent prompt. The surrounding agent selection is a static placement preview; it does not create a connection. Configure PAPERCLIP_STORYBOOK_API_URL to include a real instance origin in copied instructions. Otherwise the prompt asks for the instance URL." } },
docs: { description: { component: "Production animated setup button, prompt preview, and clipboard feedback. The surrounding agent selection is a static placement preview; it does not create a connection. Configure PAPERCLIP_STORYBOOK_API_URL to include a real instance origin in copied instructions. Otherwise the prompt asks for the instance URL." } },
},
args: { instanceUrl },
} satisfies Meta<typeof SlackSetupPrompt>;