mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user