Merge recorded master baseline into Pi 1 production candidate

Integrate 8ec4b84e1c before runtime qualification, preserving the new workspace restore lock, continuation and Docker packaging fixes. Pi profile 11 source and distribution inputs are unchanged by this merge.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-01 15:17:17 -05:00
commit 5eba61ece9
61 files changed
+2894 -2014

No files matched your search

+6
View File
@@ -55,6 +55,12 @@ describe("heartbeatsApi.liveRunsForCompany", () => {
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50");
});
it("requests distinct task cards before the server applies the dashboard limit", async () => {
await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 4, limit: 4, distinctTasks: true });
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4&limit=4&distinctTasks=true");
});
});
describe("heartbeatsApi.downloadProviderTrace", () => {
+2 -1
View File
@@ -232,7 +232,7 @@ export const heartbeatsApi = {
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (
companyId: string,
options?: number | { minCount?: number; limit?: number },
options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean },
) => {
const searchParams = new URLSearchParams();
if (typeof options === "number") {
@@ -241,6 +241,7 @@ export const heartbeatsApi = {
if (options.minCount)
searchParams.set("minCount", String(options.minCount));
if (options.limit) searchParams.set("limit", String(options.limit));
if (options.distinctTasks) searchParams.set("distinctTasks", "true");
}
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(
@@ -146,6 +146,7 @@ describe("ActiveAgentsPanel", () => {
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
distinctTasks: false,
});
const moreLink = [...container.querySelectorAll("a")].find((anchor) =>
@@ -159,7 +160,54 @@ describe("ActiveAgentsPanel", () => {
});
});
it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => {
const repeatedIssueId = "issue-repeated";
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, repeatedIssueId),
{ ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" },
createIssueRun(3, "issue-other"),
createRun(4),
createRun(5),
createRun(6),
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" dedupeLinkedTasks />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
distinctTasks: true,
});
expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) =>
card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"),
)).toEqual([
"/agents/agent-1/runs/run-1",
"/agents/agent-3/runs/run-3",
"/agents/agent-4/runs/run-4",
"/agents/agent-5/runs/run-5",
]);
expect(container.textContent).toContain("1 more active/recent run");
await act(async () => root.unmount());
});
it("can request the full live dashboard page limit without a hidden-runs link", async () => {
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, "issue-repeated"),
{ ...createIssueRun(2, "issue-repeated"), status: "succeeded" },
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -184,9 +232,11 @@ describe("ActiveAgentsPanel", () => {
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 50,
limit: 50,
distinctTasks: false,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2);
await act(async () => {
root.unmount();
+25 -5
View File
@@ -44,6 +44,7 @@ interface ActiveAgentsPanelProps {
queryScope?: string;
showMoreLink?: boolean;
showTranscripts?: boolean;
dedupeLinkedTasks?: boolean;
}
export function ActiveAgentsPanel({
@@ -58,25 +59,44 @@ export function ActiveAgentsPanel({
queryScope = "dashboard",
showMoreLink = true,
showTranscripts = false,
dedupeLinkedTasks = false,
}: ActiveAgentsPanelProps) {
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const;
const effectiveFetchLimit = fetchLimit;
const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const;
const sharedLiveRuns = useSharedPollingQuery({
companyId,
resourceKey: `live-runs:${queryScope}:${minRunCount}:${fetchLimit ?? "default"}`,
resourceKey: `live-runs:${queryScope}:${minRunCount}:${effectiveFetchLimit ?? "default"}:${dedupeLinkedTasks}`,
queryKey: liveRunsQueryKey,
enabled: !!companyId,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, {
minCount: minRunCount,
limit: effectiveFetchLimit,
distinctTasks: dedupeLinkedTasks,
}),
enabled: sharedLiveRuns.enabled,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const cardRuns = useMemo(() => {
if (!dedupeLinkedTasks) return runs;
// The endpoint orders active runs first, then recent completed runs. Keep
// the first run for each task so an active attempt wins over its history.
const seenIssueIds = new Set<string>();
return runs.filter((run) => {
if (!run.issueId) return true;
if (seenIssueIds.has(run.issueId)) return false;
seenIssueIds.add(run.issueId);
return true;
});
}, [dedupeLinkedTasks, runs]);
const visibleRuns = useMemo(() => cardRuns.slice(0, cardLimit), [cardLimit, cardRuns]);
const hiddenRunCount = Math.max(0, cardRuns.length - visibleRuns.length);
const visibleIssueIds = useMemo(
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
[visibleRuns],
+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; }
}
+11 -4
View File
@@ -177,8 +177,12 @@ function buildAgentIconMask(iconName: string | null): string | null {
if (cached) return cached;
const Icon = getAgentIcon(iconName);
const iconNode = resolveLucideIconNode(Icon);
if (!Array.isArray(iconNode) || iconNode.length === 0) return null;
// Lucide's component internals differ between browser and server builds.
// Keep the agent marker visible when its private iconNode is unavailable.
const iconNode = resolveLucideIconNode(Icon) ?? [
["rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }],
["path", { d: "M8 10h.01M16 10h.01M8 15h8" }],
];
const body = iconNode.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
@@ -212,12 +216,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;
+25 -1
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import { clearLegacyChatMessageRequests } from "./chat-message-request";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
orderChatAgents,
parseRecentAgentChats,
recordedAgentChatIssueId,
recordAgentChatVisit,
} from "./recent-agent-chats";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
@@ -57,6 +58,29 @@ describe("agent chat navigation and session markers", () => {
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
).toEqual(["agent4"]);
});
it("remembers an existing conversation and clears its identity for a new empty chat", () => {
recordAgentChatVisit("company-c", "user1", "agent1", "issue-1");
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBe("issue-1");
expect(recordedAgentChatIssueId("company-c", "user2", "agent1")).toBeNull();
recordAgentChatVisit("company-c", "user1", "agent1", null);
expect(recordedAgentChatIssueId("company-c", "user1", "agent1")).toBeNull();
});
it("recognizes a conversation cleared in another tab while retaining failed-write fallback", () => {
const storageKey = "paperclip.recentAgentChatIssue:company-d:user1:agent1";
recordAgentChatVisit("company-d", "user1", "agent1", "issue-1");
localStorage.removeItem(storageKey);
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBeNull();
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable");
});
try {
recordAgentChatVisit("company-d", "user1", "agent1", "issue-2");
expect(recordedAgentChatIssueId("company-d", "user1", "agent1")).toBe("issue-2");
} finally {
setItem.mockRestore();
}
});
it("removes legacy plaintext retry records", () => {
const scope = "company:user:agent";
const key = `paperclip:agent-chat-pending:${scope}`;
+25
View File
@@ -4,6 +4,9 @@ const eventName = "paperclip:recent-agent-chats";
const key = (company: string, user?: string | null) =>
`paperclip.recentAgentChats:${company}:${user ?? "__local_board__"}`;
const memory = new Map<string, string>();
const issueKey = (company: string, user: string | null | undefined, agentId: string) =>
`paperclip.recentAgentChatIssue:${company}:${user ?? "__local_board__"}:${agentId}`;
const rememberedIssues = new Map<string, string>();
function read(storageKey: string): string {
try {
return (
@@ -29,6 +32,7 @@ export function recordAgentChatVisit(
company: string,
user: string | null | undefined,
agentId: string,
issueId?: string | null,
) {
const storageKey = key(company, user);
const value = JSON.stringify(
@@ -43,8 +47,29 @@ export function recordAgentChatVisit(
} catch {
/* In-tab navigation still works without storage. */
}
if (issueId !== undefined) {
const storageIssueKey = issueKey(company, user, agentId);
try {
if (issueId) window.localStorage.setItem(storageIssueKey, issueId);
else window.localStorage.removeItem(storageIssueKey);
// Storage is authoritative when available, including when another tab
// removes this key. Only retain a fallback after a failed write.
rememberedIssues.delete(storageIssueKey);
} catch {
if (issueId) rememberedIssues.set(storageIssueKey, issueId);
else rememberedIssues.delete(storageIssueKey);
}
}
window.dispatchEvent(new Event(eventName));
}
export function recordedAgentChatIssueId(company: string, user: string | null | undefined, agentId: string): string | null {
const storageIssueKey = issueKey(company, user, agentId);
try {
return window.localStorage.getItem(storageIssueKey) ?? rememberedIssues.get(storageIssueKey) ?? null;
} catch {
return rememberedIssues.get(storageIssueKey) ?? null;
}
}
function subscribe(callback: () => void) {
window.addEventListener(eventName, callback);
window.addEventListener("storage", callback);
+3 -3
View File
@@ -48,9 +48,9 @@ export function AgentChat() {
creating.current = null;
}, [selectedCompanyId, userId, agent?.id]);
useEffect(() => {
if (enabled && agent && session.isFetched)
recordAgentChatVisit(agent.companyId, userId, agent.id);
}, [enabled, agent?.id, agent?.companyId, userId, session.isFetched]);
if (enabled && agent && session.isSuccess && chat.isSuccess)
recordAgentChatVisit(agent.companyId, userId, agent.id, chat.data?.id ?? null);
}, [enabled, agent?.id, agent?.companyId, userId, session.isSuccess, chat.isSuccess, chat.data?.id]);
const ensureIssue = useCallback(async () => {
if (!agent || !selectedCompanyId) throw new Error("Agent not found");
if (chat.data) return chat.data;
+150
View File
@@ -0,0 +1,150 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
import { AgentChats } from "./AgentChats";
const state = vi.hoisted(() => ({
companyId: "company-a",
userId: "user-a",
agents: [] as Agent[],
chats: [] as { id: string; conversationAgentId: string }[],
chatsFetched: true,
chatsError: null as Error | null,
sessionError: null as Error | null,
navigate: vi.fn(),
}));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({
useAgentChatNavigation: () => ({
companyId: state.companyId,
userId: state.userId,
enabled: true,
loaded: true,
agents: { data: state.agents, isFetched: true, isPending: false, error: null },
chats: { data: state.chats, isFetched: state.chatsFetched, isSuccess: state.chatsFetched && !state.chatsError, error: state.chatsError },
session: { isFetched: !state.sessionError, isPending: false, error: state.sessionError },
}),
useOpenAgentChat: () => vi.fn(),
}));
vi.mock("@/lib/router", () => ({
useNavigate: () => state.navigate,
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
}));
vi.mock("@/components/AgentAvatar", () => ({ AgentAvatar: () => null }));
let root: Root;
let container: HTMLDivElement;
const agent = (id: string, companyId = "company-a") => ({
id, companyId, name: id, urlKey: `${id}-slug`, role: "engineer", status: "idle",
} as Agent);
async function render() {
await act(async () => root.render(<AgentChats />));
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
state.companyId = "company-a";
state.userId = "user-a";
state.agents = [agent("alice"), agent("bob")];
state.chats = [];
state.chatsFetched = true;
state.chatsError = null;
state.sessionError = null;
state.navigate.mockReset();
for (const id of ["alice", "bob", "removed", "retired-id"])
recordAgentChatVisit("company-a", "user-a", id, null);
localStorage.setItem("paperclip.recentAgentChats:company-a:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-b:user-a", "[]");
localStorage.setItem("paperclip.recentAgentChats:company-a:user-b", "[]");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
describe("Chat landing", () => {
it("opens the most recently visited agent and replaces the landing history entry", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true });
expect(container.textContent).toContain("Opening chat");
});
it("skips unavailable saved agents and preserves the company and user scope", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "removed");
recordAgentChatVisit("company-b", "user-a", "other-company-agent");
recordAgentChatVisit("company-a", "user-b", "other-user-agent");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens historical chats for agents missing from the active roster", async () => {
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("waits for history before skipping a recent historical chat", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "retired-id");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
state.chatsFetched = true;
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
});
it("shows the chooser when no saved chat remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "removed");
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.textContent).toContain("Who would you like to talk to?");
});
it("skips a saved conversation that is no longer accessible even when its agent remains available", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
recordAgentChatVisit("company-a", "user-a", "bob", "deleted-chat");
state.chats = [{ id: "chat-alice", conversationAgentId: "alice" }];
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens an active agent when history fails", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", "chat-alice");
state.chatsFetched = false;
state.chatsError = new Error("History unavailable");
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("reopens a visited empty chat without waiting for history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice", null);
state.chatsFetched = false;
await render();
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
});
it("shows a retry state when identity fails instead of waiting for disabled history", async () => {
recordAgentChatVisit("company-a", "user-a", "alice");
state.sessionError = new Error("Session unavailable");
state.chatsFetched = false;
await render();
expect(state.navigate).not.toHaveBeenCalled();
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your chats");
});
});
+34 -6
View File
@@ -4,30 +4,58 @@ import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useAgentChatNavigation, useOpenAgentChat } from "@/hooks/useAgentChatNavigation";
import { Link } from "@/lib/router";
import { recordedAgentChatIssueId, useRecentAgentChats } from "@/lib/recent-agent-chats";
import { Link, useNavigate } from "@/lib/router";
import { agentRouteRef } from "@/lib/utils";
export function AgentChats() {
const { setBreadcrumbs } = useBreadcrumbs();
const { companyId, userId, enabled, loaded, agents, session } = useAgentChatNavigation();
const { companyId, userId, enabled, loaded, agents, chats, session } = useAgentChatNavigation();
useEffect(() => setBreadcrumbs([{ label: "Chat" }]), [setBreadcrumbs]);
return <AgentChatsContent key={`${companyId}:${userId}`} companyId={companyId} userId={userId}
enabled={enabled} loaded={loaded} agents={agents} session={session} />;
enabled={enabled} loaded={loaded} agents={agents} chats={chats} session={session} />;
}
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "session">) {
function AgentChatsContent({ companyId, userId, enabled, loaded, agents, chats, session }: Pick<ReturnType<typeof useAgentChatNavigation>, "companyId" | "userId" | "enabled" | "loaded" | "agents" | "chats" | "session">) {
const openChat = useOpenAgentChat(companyId, userId);
const navigate = useNavigate();
const recentIds = useRecentAgentChats(companyId ?? "", userId);
const [openingId, setOpeningId] = useState<string | null>(null);
const [openError, setOpenError] = useState<string | null>(null);
const blockingError = agents.error ?? session.error;
const firstId = recentIds[0];
const firstAgent = agents.data?.find(agent => agent.id === firstId);
// An empty chat with an active agent can reopen from the roster alone. A
// missing agent or a saved issue needs history to validate that visit.
const needsHistory = !!firstId && (!firstAgent || !!recordedAgentChatIssueId(companyId ?? "", userId, firstId));
const resolvingRecent = recentIds.length > 0 && !blockingError && (!agents.isFetched || !session.isFetched || (needsHistory && !chats.isFetched && !chats.error));
const recentChatPath = loaded && enabled && companyId && !blockingError && !resolvingRecent
? recentIds.map(id => {
const agent = agents.data?.find(item => item.id === id);
const historyIssue = chats.data?.find(chat => chat.conversationAgentId === id);
const savedIssueId = recordedAgentChatIssueId(companyId, userId, id);
// A deleted or inaccessible issue must not silently reopen as a new chat.
if (savedIssueId && chats.isSuccess && historyIssue?.id !== savedIssueId) return null;
if (agent) return `/chats/${encodeURIComponent(agentRouteRef(agent))}`;
if (historyIssue) return `/chats/${encodeURIComponent(id)}`;
return null;
}).find((path): path is string => path !== null)
: undefined;
useEffect(() => {
if (recentChatPath) navigate(recentChatPath, { replace: true });
}, [navigate, recentChatPath]);
if (!loaded) return <p role="status" className="text-sm text-muted-foreground">Loading chat…</p>;
if (!enabled) return <p className="text-sm text-muted-foreground">Agent Chat is disabled. Enable it in Experimental settings.</p>;
const error = agents.error ?? session.error;
if (!companyId) return <p className="text-sm text-muted-foreground">Select a company to start a conversation.</p>;
if (resolvingRecent || recentChatPath) return <p role="status" className="text-sm text-muted-foreground">Opening chat…</p>;
const error = blockingError ?? (recentIds.length > 0 ? chats.error : null);
return <div className="mx-auto flex h-full max-w-xl flex-col justify-center gap-6 px-4 py-12">
<div className="flex flex-col gap-3">
<MessageCircle className="size-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Who would you like to talk to?</h1>
<p className="text-sm leading-relaxed text-muted-foreground">Ask a question, think through an idea, or plan the next step with your team.</p>
</div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your agents.</p><Button variant="outline" onClick={() => { void agents.refetch(); void session.refetch(); }}>Try again</Button></div>
{error ? <div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm">Couldn’t load your chats.</p><Button variant="outline" onClick={() => { void agents.refetch(); void chats.refetch(); void session.refetch(); }}>Try again</Button></div>
: agents.isPending || session.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agents…</p>
: <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(agents.data ?? []).slice(0, 6).map(agent => <button key={agent.id} type="button" disabled={openingId !== null}
+1 -1
View File
@@ -374,7 +374,7 @@ export function Dashboard() {
</div>
)}
<ActiveAgentsPanel companyId={selectedCompanyId!} />
<ActiveAgentsPanel companyId={selectedCompanyId!} dedupeLinkedTasks />
{data && (
<>
+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>
@@ -10,7 +10,7 @@ describe("Apps agent selector contract", () => {
const appConnect = source("./AppsConnect.tsx");
const connectionSetupFlow = source("../../features/connections/ConnectionSetupFlow.tsx");
const permissions = source("./app-detail/PermissionsPanel.tsx");
const tester = source("./app-detail/TestPanel.tsx");
const tester = source("./app-detail/ActionTestDialog.tsx");
const profiles = source("../tools/ProfilesTab.tsx");
const audit = source("../tools/AuditTab.tsx");
@@ -0,0 +1,295 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { ActionTestDialog, errorHints } from "./ActionTestDialog";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) => getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, requestId: string) => getTestCallStatusMock(connectionId, requestId),
declineActionRequest: (companyId: string, requestId: string) => declineActionRequestMock(companyId, requestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const entry = {
id: "asana-list-projects",
companyId: "company-1",
applicationId: "app-asana",
connectionId: "conn-1",
entryKind: "tool",
toolName: "list_projects",
title: "List projects",
description: "Find Asana projects.",
inputSchema: { type: "object", properties: {} },
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-09-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-09-01T00:00:00Z"),
lastSeenAt: new Date("2026-10-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-09-01T00:00:00Z"),
updatedAt: new Date("2026-10-01T00:00:00Z"),
} as ToolCatalogEntry;
const askFirstEntry = {
...entry,
id: "asana-create-task",
toolName: "create_task",
title: "Create task",
riskLevel: "write",
isReadOnly: false,
isWrite: true,
} as ToolCatalogEntry;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let client: QueryClient;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => { result = callback(); });
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
async function renderDialog(testEntry = entry) {
await act(() => root.render(
<QueryClientProvider client={client}>
<ActionTestDialog connectionId="conn-1" appName="Asana" entry={testEntry} open onOpenChange={() => undefined} />
</QueryClientProvider>,
));
await flushReact();
}
async function openAndRun(testEntry = entry) {
await renderDialog(testEntry);
const button = [...document.body.querySelectorAll("button")].find((candidate) => candidate.textContent?.trim() === "Run");
expect(button).toBeTruthy();
await act(() => button!.click());
await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 260)); });
await flushReact();
}
function allowAskFirst() {
getTestAgentAccessMock.mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 0, askFirstCount: 1, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "create_task", gatewayToolName: "asana__create_task", displayName: "Create task", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "ask-invocation", actionRequestId: "request-1" });
}
function requestStatus(phase: "waiting" | "running" | "done" | "denied") {
return {
actionRequestId: "request-1", invocationId: "ask-invocation", phase,
parameters: {}, requestedAt: "2026-10-01T00:00:00.000Z",
resolvedAt: phase === "waiting" ? null : "2026-10-01T00:00:05.000Z",
durationMs: phase === "done" ? 1200 : null,
};
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset().mockResolvedValue({
agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
});
getTestAgentAccessMock.mockReset().mockResolvedValue({
access: {
connectionId: "conn-1", toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0,
lastChangedAt: null, lastChangedByAgentId: null, lastChangedByName: null,
tools: [{ toolName: "list_projects", gatewayToolName: "asana__list_projects", displayName: "List projects", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] }],
},
});
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset().mockResolvedValue(requestStatus("waiting"));
declineActionRequestMock.mockReset();
window.sessionStorage.clear();
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("Permissions action Test dialog", () => {
it("renders structured MCP rows and keeps the full response behind the raw disclosure", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured",
result: {
content: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}',
data: {
content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"},{"name":"Customer onboarding"}]}' }],
structuredContent: { data: [{ name: "Q4 launch" }, { name: "Customer onboarding" }] },
isError: false, transport: "mcp_http",
},
},
});
await openAndRun();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", { agentId: "agent-ceo", toolName: "list_projects", parameters: {} });
expect(document.body.textContent).toContain("Worked. 2 rows came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
expect(document.body.textContent).not.toContain("structuredContent");
const raw = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Show raw response");
await act(() => raw!.click());
expect(document.body.textContent).toContain("structuredContent");
});
it("parses JSON text when structured content is absent", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "text",
result: { content: '{"data":[{"name":"Q4 launch"}]}', data: { content: [{ type: "text", text: '{"data":[{"name":"Q4 launch"}]}' }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")?.textContent).toContain("Q4 launch");
});
it("uses structured content when an MCP result has no content blocks", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "structured-only",
result: { content: "", data: { structuredContent: { results: [{ name: "Northstar launch plan" }] }, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Worked. 1 row came back.");
expect(document.body.querySelector("table")?.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).not.toContain("structuredContent");
});
it("shows wide search results as readable cards with a safe page link", async () => {
const rows = [{
id: "11111111-2222-4333-8444-555555555555",
title: "Northstar launch plan",
url: "https://www.notion.so/11111111222243338444555555555555",
type: "page",
highlight: "The launch plan covers milestones, owners, and a detailed summary that should not stretch a table row in the dialog.",
timestamp: "2026-09-10T14:30:00.000Z",
}];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "notion-wide",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.querySelector("table")).toBeNull();
expect(document.body.textContent).toContain("Northstar launch plan");
expect(document.body.textContent).toContain("The launch plan covers milestones");
expect(document.body.querySelector('a[href="https://www.notion.so/11111111222243338444555555555555"]')?.textContent).toBe("Open link");
});
it("shows a non-link URL as a field in a result card", async () => {
const rows = [{ title: "Northstar launch plan", url: "notion://workspace/launch", description: "A long description that keeps the card layout useful even when the provider supplies a URL that cannot open in a browser." }];
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "non-link-url",
result: { content: JSON.stringify({ results: rows }), data: { content: [{ type: "text", text: JSON.stringify({ results: rows }) }], isError: false } },
});
await openAndRun();
expect(document.body.querySelector('a[href="notion://workspace/launch"]')).toBeNull();
expect(document.body.textContent).toContain("notion://workspace/launch");
});
it("keeps an ask-first request visible after reopening the dialog", async () => {
allowAskFirst();
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Sent for your OK.");
expect(document.body.textContent).toContain("Cancel this request");
expect(document.body.querySelector('a[href="/apps/conn-1/review"]')).toBeTruthy();
await act(() => root.unmount());
root = createRoot(container);
await renderDialog(askFirstEntry);
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Sent for your OK.");
});
it("shows an approved request running and then the completed result", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("running"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Approved · running");
expect(document.body.textContent).not.toContain("Cancel this request");
getTestCallStatusMock.mockResolvedValue({ ...requestStatus("done"), result: [{ name: "Northstar launch plan" }] });
await act(async () => { await client.invalidateQueries({ queryKey: ["tools"] }); });
await flushReact();
expect(document.body.textContent).toContain("Worked.");
expect(document.body.textContent).toContain("Northstar launch plan");
expect(runTestCallMock).toHaveBeenCalledTimes(1);
});
it("shows a denied request without offering cancellation", async () => {
allowAskFirst();
getTestCallStatusMock.mockResolvedValue(requestStatus("denied"));
await openAndRun(askFirstEntry);
expect(document.body.textContent).toContain("Denied — see Review for why");
expect(document.body.textContent).not.toContain("Cancel this request");
});
it("shows a tool error and opens its raw response", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "error",
result: { content: "Asana denied access to this workspace.", data: { content: [{ type: "text", text: "Asana denied access to this workspace." }], isError: true }, error: "MCP tool returned an error result" },
});
await openAndRun();
expect(document.body.textContent).toContain("It didn't work.");
expect(document.body.textContent).toContain("Asana denied access to this workspace.");
expect(document.body.textContent).toContain("Hide raw response");
});
it("opens the raw response when an MCP block has an unexpected shape", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed", invocationId: "malformed",
result: { content: "", data: { content: [{ type: "text", payload: "missing text" }], structuredContent: null, isError: false } },
});
await openAndRun();
expect(document.body.textContent).toContain("Hide raw response");
expect(document.body.textContent).toContain("missing text");
});
it("keeps useful and generic error hints", () => {
expect(errorHints("Not found", "NOT_FOUND").join(" ")).toMatch(/check|verify|ID/i);
expect(errorHints("Unexpected provider failure", null).length).toBeGreaterThan(0);
});
});
@@ -4,7 +4,6 @@ import {
AlertTriangle,
Ban,
Check,
ChevronDown,
ChevronsUpDown,
Clock,
Loader2,
@@ -28,12 +27,6 @@ import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Dialog,
@@ -50,7 +43,6 @@ import {
} from "@/components/JsonSchemaForm";
import { cn, relativeTime } from "@/lib/utils";
import { appTabHref } from "../app-tabs";
import { formatActionPermissionSummary } from "./action-permission-summary";
// ---------------------------------------------------------------------------
// Small format helpers
@@ -67,13 +59,6 @@ function relTime(date: Date): string {
return t.charAt(0).toUpperCase() + t.slice(1);
}
/** Sub-line copy: first sentence of the catalog description, no trailing period. */
function actionSubLine(entry: ToolCatalogEntry): string | null {
if (!entry.description) return null;
const firstSentence = entry.description.split(/(?<=\.)\s/)[0] ?? entry.description;
return firstSentence.replace(/\.+$/, "").trim() || null;
}
// ---------------------------------------------------------------------------
// Decision badges
// ---------------------------------------------------------------------------
@@ -88,9 +73,7 @@ const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000;
const TEST_ACCESS_GC_TIME_MS = 30 * 60_000;
/**
* Focused action tester used by the combined Permissions page. The modal keeps
* the existing schema form and result renderer, but scopes agent selection and
* test state to the action the user opened.
* Action tester opened from an action row on the Permissions page.
*/
export function ActionTestDialog({
connectionId,
@@ -185,7 +168,6 @@ export function ActionTestDialog({
onSelect={setRequestedAgentId}
connectionId={connectionId}
appName={appName}
inline
/>
<DecisionBadge decision={decision} />
</div>
@@ -236,262 +218,6 @@ function DecisionBadge({ decision }: { decision: ToolConnectionTestDecision }) {
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function TestPanel({
connectionId,
appName,
active,
quarantined = [],
}: {
connectionId: string;
appName: string;
/** Active (non-quarantined, non-removed) catalog entries. */
active: ToolCatalogEntry[];
/** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
quarantined?: ToolCatalogEntry[];
}) {
const hasActions = active.length > 0 || quarantined.length > 0;
const testAgentsQuery = useQuery({
queryKey: queryKeys.tools.testAgents(connectionId),
queryFn: () => toolsApi.listTestAgents(connectionId),
enabled: !!connectionId && hasActions,
});
const agents = useMemo(
() => [...(testAgentsQuery.data?.agents ?? [])].sort(
(a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name),
),
[testAgentsQuery.data],
);
const [requestedAgentId, setRequestedAgentId] = useState<string | null>(null);
// The API returns only agents this user may write to. Prefer the highest
// agent in that accessible slice of the org tree, regardless of whether a
// lower-ranked agent happens to have a broader app policy today.
const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId)
? requestedAgentId
: agents[0]?.id ?? null;
const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null;
const testAgentAccessQuery = useQuery({
queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"),
queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!),
enabled: !!connectionId && !!agentId && hasActions,
staleTime: TEST_ACCESS_STALE_TIME_MS,
gcTime: TEST_ACCESS_GC_TIME_MS,
refetchOnWindowFocus: false,
});
const selectedAgent = useMemo<TestAgentWithAccess | null>(() => (
selectedAgentBase && testAgentAccessQuery.data
? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access }
: null
), [selectedAgentBase, testAgentAccessQuery.data]);
// Per-action decision for the selected agent, keyed by both the upstream and
// gateway tool names so we can match whatever the catalog stores.
const decisionByTool = useMemo(() => {
const map = new Map<string, ToolConnectionTestDecision>();
for (const tool of selectedAgent?.effectiveAccess.tools ?? []) {
map.set(tool.toolName, tool.decision);
map.set(tool.gatewayToolName, tool.decision);
}
return map;
}, [selectedAgent]);
const decisionFor = (entry: ToolCatalogEntry): ToolConnectionTestDecision =>
decisionByTool.get(entry.toolName) ?? "off";
// Search + read/write filter.
const [query, setQuery] = useState("");
const [kindFilter, setKindFilter] = useState<"all" | "read" | "write">("all");
const byName = (a: ToolCatalogEntry, b: ToolCatalogEntry) =>
(a.title ?? a.toolName).localeCompare(b.title ?? b.toolName);
const readActions = active.filter((e) => e.isReadOnly).sort(byName);
const writeActions = active.filter((e) => !e.isReadOnly).sort(byName);
const matches = (entry: ToolCatalogEntry) => {
if (kindFilter === "read" && !entry.isReadOnly) return false;
if (kindFilter === "write" && entry.isReadOnly) return false;
const needle = query.trim().toLowerCase();
if (!needle) return true;
return (
(entry.title ?? entry.toolName).toLowerCase().includes(needle) ||
(entry.description ?? "").toLowerCase().includes(needle)
);
};
const quarantinedActions = [...quarantined].sort(byName);
const visibleRead = readActions.filter(matches);
const visibleWrite = writeActions.filter(matches);
const visibleQuarantined = quarantinedActions.filter(matches);
const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
if (!hasActions) {
return <EmptyState connectionId={connectionId} appName={appName} />;
}
if (testAgentsQuery.isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agents…
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
if (testAgentsQuery.isError) {
return (
<TestLoadError
message="We couldn't load the agents available for testing."
onRetry={() => { void testAgentsQuery.refetch(); }}
/>
);
}
if (agents.length === 0) {
return (
<div className="py-6 text-center">
<p className="text-sm font-medium text-foreground">No agents to test as</p>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
Only agents you can assign tasks to can preview {appName}. Give an agent access in{" "}
<Link className="font-medium text-primary hover:underline" to={appTabHref(connectionId, "permissions")}>
Permissions
</Link>{" "}
to test it here.
</p>
</div>
);
}
if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) {
return (
<TestLoadError
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
onRetry={() => { void testAgentAccessQuery.refetch(); }}
/>
);
}
if (testAgentAccessQuery.isLoading || !selectedAgent) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agent permissions…
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
const sharedRowProps = {
connectionId,
appName,
allAgents: agents,
onSelectAgent: setRequestedAgentId,
};
return (
<div className="space-y-8">
{selectedAgent && (
<TestAsHeader
appName={appName}
agents={agents}
selectedAgent={selectedAgent}
onSelect={setRequestedAgentId}
connectionId={connectionId}
/>
)}
<section className="space-y-4 border-t border-border pt-8">
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-(--sz-12rem) flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Find an action"
placeholder="Find an action…"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<FilterChip label={`All ${active.length + quarantinedActions.length}`} active={kindFilter === "all"} onClick={() => setKindFilter("all")} />
<FilterChip label={`Read ${readActions.length}`} active={kindFilter === "read"} onClick={() => setKindFilter("read")} />
<FilterChip label={`Write ${writeActions.length}`} active={kindFilter === "write"} onClick={() => setKindFilter("write")} />
</div>
<p className="text-xs text-muted-foreground">{visibleCount} matches · sorted A–Z</p>
</section>
{visibleCount === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No actions match “{query}”. Clear the search to see them all.
</div>
) : (
<div className="space-y-6">
{visibleRead.length > 0 && selectedAgent && (
<ActionGroup
heading={`Read (${visibleRead.length})`}
entries={visibleRead}
decisionFor={decisionFor}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
{visibleWrite.length > 0 && selectedAgent && (
<ActionGroup
heading={`Write (${visibleWrite.length})`}
entries={visibleWrite}
decisionFor={decisionFor}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
{visibleQuarantined.length > 0 && selectedAgent && (
<ActionGroup
heading={`New (${visibleQuarantined.length})`}
subheading="New actions wait, switched off, until you turn them on."
entries={visibleQuarantined}
decisionFor={() => "off" as const}
agent={selectedAgent}
{...sharedRowProps}
/>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ connectionId, appName }: { connectionId: string; appName: string }) {
return (
<div className="py-8 text-center">
<p className="text-base font-bold text-foreground">Nothing to test yet</p>
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">
Once {appName} is connected, the actions it offers will show up here so you can try them out.
</p>
<Button asChild className="mt-4" variant="outline">
<Link to={appTabHref(connectionId, "permissions")}>Go to Permissions</Link>
</Button>
</div>
);
}
function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="py-8 text-center">
@@ -504,66 +230,21 @@ function TestLoadError({ message, onRetry }: { message: string; onRetry: () => v
}
// ---------------------------------------------------------------------------
// Test-as header + agent picker
// Agent picker
// ---------------------------------------------------------------------------
function TestAsHeader({
appName,
agents,
selectedAgent,
onSelect,
connectionId,
}: {
appName: string;
agents: ToolConnectionTestAgent[];
selectedAgent: TestAgentWithAccess;
onSelect: (agentId: string) => void;
connectionId: string;
}) {
return (
<section className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Test an action</h2>
<p className="mt-1 text-sm text-muted-foreground">
Run a real action as an agent.
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">Agent</p>
<AgentPicker
agents={agents}
selectedAgent={selectedAgent}
onSelect={onSelect}
connectionId={connectionId}
appName={appName}
/>
</div>
<Link
className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
to={appTabHref(connectionId, "permissions")}
>
{formatActionPermissionSummary(selectedAgent.effectiveAccess)}
</Link>
</div>
</section>
);
}
function AgentPicker({
agents,
selectedAgent,
onSelect,
connectionId,
appName,
inline,
}: {
agents: ToolConnectionTestAgent[];
selectedAgent: ToolConnectionTestAgent;
onSelect: (agentId: string) => void;
connectionId: string;
appName: string;
inline?: boolean;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -577,17 +258,14 @@ function AgentPicker({
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"items-center gap-1.5 text-foreground outline-none hover:text-primary focus-visible:text-primary",
inline ? "inline-flex font-semibold underline-offset-2 hover:underline" : "mt-0.5 flex text-lg font-bold",
)}
className="inline-flex items-center gap-1.5 font-semibold text-foreground underline-offset-2 outline-none hover:text-primary hover:underline focus-visible:text-primary"
aria-label="Choose which agent to test as"
>
{selectedAgent.name}
<ChevronsUpDown className={cn("text-muted-foreground", inline ? "h-3.5 w-3.5" : "h-4 w-4")} />
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0" disablePortal={inline}>
<PopoverContent align="start" className="w-80 p-0" disablePortal>
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -661,114 +339,6 @@ function AgentPicker({
);
}
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Action group + rows
// ---------------------------------------------------------------------------
type RowSharedProps = {
connectionId: string;
appName: string;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
};
function ActionGroup({
heading,
subheading,
entries,
decisionFor,
agent,
...shared
}: {
heading: string;
subheading?: string;
entries: ToolCatalogEntry[];
decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
return (
<section>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</h3>
{subheading && <p className="mb-1.5 -mt-1 text-xs text-muted-foreground">{subheading}</p>}
<div className="divide-y divide-border">
{entries.map((entry) => (
<ActionRow
key={entry.id}
entry={entry}
decision={decisionFor(entry)}
agent={agent}
{...shared}
/>
))}
</div>
</section>
);
}
function ActionRow({
entry,
decision,
agent,
...shared
}: {
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
const title = entry.title ?? entry.toolName;
const sub = actionSubLine(entry);
useEffect(() => {
if (loadStoredAskFirstOutcome(shared.connectionId, entry, agent)) {
setOpen(true);
}
}, [shared.connectionId, entry, agent]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left outline-none hover:bg-accent/40 focus-visible:bg-accent/40"
>
<ChevronDown
className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{title}</span>
{sub && <span className="block truncate text-xs text-muted-foreground">{sub}</span>}
</span>
<DecisionBadge decision={decision} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border py-4 pl-11">
<ActionTester entry={entry} decision={decision} agent={agent} {...shared} />
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ---------------------------------------------------------------------------
// The actual tester (form + run + result)
// ---------------------------------------------------------------------------
@@ -854,7 +424,11 @@ function ActionTester({
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: TestAgentWithAccess;
} & RowSharedProps) {
connectionId: string;
appName: string;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
}) {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const rawSchema = (entry.inputSchema ?? { type: "object", properties: {} }) as JsonSchemaNode;
@@ -1202,6 +776,59 @@ function isEmptyResult(value: unknown): boolean {
return false;
}
/** The gateway keeps the original MCP blocks inside `data`; the top-level
* `content` is only a flattened convenience string. Prefer structured output,
* then parse individual text blocks when they happen to contain JSON. */
function resultPreview(value: unknown): { items: Array<{ label: string | null; value: unknown }>; summary: unknown; rawFallback?: boolean } {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { items: [{ label: null, value }], summary: value };
}
const wrapper = value as Record<string, unknown>;
const data = wrapper.data && typeof wrapper.data === "object" && !Array.isArray(wrapper.data)
? wrapper.data as Record<string, unknown>
: null;
const result = data && (Array.isArray(data.content) || data.structuredContent != null) ? data : wrapper;
const blocks = Array.isArray(result.content) ? result.content : null;
if (!blocks && result.structuredContent == null) {
return { items: [{ label: null, value }], summary: value };
}
const parseText = (text: string): unknown => {
try { return JSON.parse(text); } catch { return text; }
};
const structured = result.structuredContent;
const items: Array<{ label: string | null; value: unknown }> = [];
const structuredValue = typeof structured === "string" ? parseText(structured) : structured;
if (structured !== null && structured !== undefined) {
items.push({ label: null, value: structuredValue });
}
for (const block of blocks ?? []) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return { items: [{ label: null, value }], summary: value, rawFallback: true };
}
const content = block as Record<string, unknown>;
if (content.type === "text" && typeof content.text === "string") {
const parsed = parseText(content.text);
if (structured === null || structured === undefined || safeStringify(parsed) !== safeStringify(structuredValue)) {
items.push({ label: (blocks?.length ?? 0) > 1 || (structured !== null && structured !== undefined) ? "Text" : null, value: parsed });
}
} else if (content.type === "image" || content.type === "audio") {
items.push({ label: content.type === "image" ? "Image" : "Audio", value: typeof content.mimeType === "string" ? content.mimeType : "Media attachment" });
} else if (content.type === "resource_link") {
items.push({ label: "Resource", value: content.title ?? content.name ?? content.uri ?? "Resource link" });
} else if (content.type === "resource" && content.resource && typeof content.resource === "object") {
const resource = content.resource as Record<string, unknown>;
items.push({ label: "Resource", value: typeof resource.text === "string" ? parseText(resource.text) : resource.uri ?? "Embedded resource" });
} else {
return { items: [{ label: null, value }], summary: value, rawFallback: true };
}
}
if (items.length === 0 && typeof wrapper.content === "string") {
items.push({ label: null, value: parseText(wrapper.content) });
}
return { items, summary: structured !== null && structured !== undefined ? structuredValue : items[0]?.value };
}
function writeVerb(entry: ToolCatalogEntry): string | null {
const n = `${entry.toolName} ${entry.title ?? ""}`.toLowerCase();
if (/\b(append|add|insert|create|new)\b/.test(n)) return "added";
@@ -1231,27 +858,35 @@ function AllowedResult({
connectionId: string;
}) {
const value = outcome.result.result;
const preview = resultPreview(value);
return (
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-foreground">{successHeadline(value, entry, appName)}</span>
<span className="text-sm font-medium text-foreground">{successHeadline(preview.summary, entry, appName)}</span>
</div>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Ran as {outcome.agentName} · {seconds(outcome.durationMs)} · {relTime(outcome.ranAt)}
</p>
{!isEmptyResult(value) && (
{preview.items.some((item) => !isEmptyResult(item.value)) && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Preview</p>
<div className="mt-1.5">
<PrettyPreview value={value} />
<div className="space-y-2">
{preview.items.map((item, index) => (
<div key={index}>
{item.label && <p className="mb-1 text-xs font-medium text-muted-foreground">{item.label}</p>}
<PrettyPreview value={item.value} />
</div>
))}
</div>
</div>
</div>
)}
<RawResponseDisclosure value={value} />
<RawResponseDisclosure value={value} initiallyOpen={preview.rawFallback} />
<p className="mt-3 text-xs text-muted-foreground">
This call is in the{" "}
@@ -1265,12 +900,25 @@ function AllowedResult({
);
}
/** Pretty preview: table for row arrays, depth-limited JSON otherwise, plain text for strings. */
/** Pretty preview: tables for row arrays, labeled fields for objects, plain text for prose. */
function PrettyPreview({ value }: { value: unknown }) {
const rows = asRows(value);
if (rows) {
const columns = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))).slice(0, 6);
const shown = rows.slice(0, 6);
const needsCards = columns.length > 4 || shown.some((row) =>
columns.some((column) => cellText(row[column]).length > 80)
);
if (needsCards) {
return (
<div className="space-y-2">
{shown.map((row, index) => <PreviewRowCard key={index} row={row} index={index} />)}
{rows.length > shown.length && (
<p className="text-(length:--text-micro) text-muted-foreground">… {rows.length - shown.length} more rows in the raw response</p>
)}
</div>
);
}
return (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-xs">
@@ -1300,6 +948,18 @@ function PrettyPreview({ value }: { value: unknown }) {
if (typeof value === "string") {
return <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-3 text-xs text-foreground">{value}</pre>;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
return (
<dl className="max-h-64 overflow-auto divide-y divide-border rounded-md border border-border bg-background text-xs">
{Object.entries(value as Record<string, unknown>).map(([key, field]) => (
<div key={key} className="grid grid-cols-3 gap-3 px-3 py-2">
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
<dd className="col-span-2 min-w-0 break-words whitespace-pre-wrap text-foreground">{typeof field === "object" && field !== null ? safeStringify(collapseDeep(field, 2)) : cellText(field)}</dd>
</div>
))}
</dl>
);
}
return (
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 text-xs text-foreground">
{safeStringify(collapseDeep(value, 2))}
@@ -1307,6 +967,42 @@ function PrettyPreview({ value }: { value: unknown }) {
);
}
function PreviewRowCard({ row, index }: { row: Record<string, unknown>; index: number }) {
const titleKey = ["title", "name", "label"].find((key) => typeof row[key] === "string" && row[key] !== "");
const summaryKey = ["highlight", "description", "summary"].find((key) => typeof row[key] === "string" && row[key] !== "");
const url = typeof row.url === "string" && /^https?:\/\//i.test(row.url) ? row.url : null;
const details = Object.entries(row)
.filter(([key]) => key !== titleKey && key !== summaryKey && (key !== "url" || !url))
.sort(([left], [right]) => {
const order = ["url", "type", "status", "timestamp", "id"];
const leftRank = order.indexOf(left);
const rightRank = order.indexOf(right);
return (leftRank < 0 ? order.length : leftRank) - (rightRank < 0 ? order.length : rightRank);
});
return (
<div className="min-w-0 rounded-md border border-border bg-background p-3 text-xs">
<div className="flex min-w-0 items-start justify-between gap-3">
<p className="min-w-0 break-words text-sm font-medium text-foreground">
{titleKey ? cellText(row[titleKey]) : `Result ${index + 1}`}
</p>
{url && <a className="shrink-0 text-primary hover:underline" href={url} target="_blank" rel="noopener noreferrer">Open link</a>}
</div>
{summaryKey && <p className="mt-1 line-clamp-2 break-words text-muted-foreground">{cellText(row[summaryKey]).replace(/\*\*(.*?)\*\*/g, "$1")}</p>}
{details.length > 0 && (
<dl className="mt-2 space-y-1">
{details.slice(0, 3).map(([key, field]) => (
<div key={key} className="grid grid-cols-3 gap-2">
<dt className="min-w-0 break-words text-muted-foreground">{key}</dt>
<dd className="col-span-2 min-w-0 break-all text-foreground">{cellText(field)}</dd>
</div>
))}
</dl>
)}
{details.length > 3 && <p className="mt-2 text-muted-foreground">{details.length - 3} more {details.length === 4 ? "field" : "fields"} in the raw response</p>}
</div>
);
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}";
@@ -1325,8 +1021,8 @@ function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown {
return out;
}
function RawResponseDisclosure({ value }: { value: unknown }) {
const [showRaw, setShowRaw] = useState(false);
function RawResponseDisclosure({ value, initiallyOpen = false }: { value: unknown; initiallyOpen?: boolean }) {
const [showRaw, setShowRaw] = useState(initiallyOpen);
if (value === undefined || value === null) return null;
return (
<div className="mt-3">
@@ -1384,6 +1080,7 @@ function ErrorResult({
))}
</ul>
</div>
{outcome.result.result !== undefined && <RawResponseDisclosure value={outcome.result.result} initiallyOpen />}
<p className="mt-3 text-xs text-muted-foreground">
{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}
</p>
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/utils";
import { type InstallState } from "@/lib/tool-installs";
import { QuarantinedActionsReview } from "./SetupPanel";
import { ActionTestDialog } from "./TestPanel";
import { ActionTestDialog } from "./ActionTestDialog";
import type { AccessDraft, AppDetailSectionProps } from "./types";
type ActionPermission = "off" | "ask" | "allowed";
@@ -1,575 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { TestPanel, errorHints } from "./TestPanel";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) =>
getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
getTestCallStatusMock(connectionId, actionRequestId),
declineActionRequest: (companyId: string, actionRequestId: string) =>
declineActionRequestMock(companyId, actionRequestId),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
for (let i = 0; i < 4; i += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
/** The form field input — excludes the "Find an action" search box. */
function formInput(): HTMLInputElement {
const inputs = [...container.querySelectorAll("input")].filter(
(i) => i.getAttribute("aria-label") !== "Find an action",
);
return inputs[0] as HTMLInputElement;
}
async function fillFormField(value: string) {
const input = formInput();
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
}
/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
async function settle() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 260));
});
await flushReact();
}
async function clickByText(text: string) {
const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
expect(btn).toBeTruthy();
await act(async () => btn!.click());
await flushReact();
}
function catalogEntry(overrides: Partial<ToolCatalogEntry> = {}): ToolCatalogEntry {
return {
id: "catalog-read",
companyId: "company-1",
applicationId: "app-1",
connectionId: "conn-1",
entryKind: "tool",
toolName: "read_sheet",
title: "Read a sheet",
description: "Get rows and cell values from a sheet.",
inputSchema: {
type: "object",
properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
required: ["spreadsheetId"],
},
outputSchema: null,
annotations: null,
riskLevel: "read",
isReadOnly: true,
isWrite: false,
isDestructive: false,
status: "active",
addedAt: new Date("2026-01-01T00:00:00Z"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-01-01T00:00:00Z"),
lastSeenAt: new Date("2026-01-01T00:00:00Z"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
} as ToolCatalogEntry;
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-claude",
name: "ClaudeCoder",
role: "engineer",
title: "Engineer",
status: "active",
orgDepth: 1,
effectiveAccess: {
connectionId: "conn-1",
toolCount: 3,
allowedCount: 1,
askFirstCount: 1,
offCount: 1,
lastChangedAt: null,
lastChangedByAgentId: null,
lastChangedByName: null,
tools: [
{ toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
{ toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
],
},
...overrides,
};
}
const readEntry = catalogEntry();
const writeAskEntry = catalogEntry({
id: "catalog-append",
toolName: "append_row",
title: "Append a row",
description: "Add a new row to the bottom of a sheet.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
});
const offEntry = catalogEntry({
id: "catalog-delete",
toolName: "delete_row",
title: "Delete a row",
description: "Remove a row from a sheet permanently.",
isReadOnly: false,
isWrite: true,
isDestructive: true,
riskLevel: "destructive",
});
let container: HTMLDivElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let root: any;
let client: QueryClient;
function renderPanel(
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
quarantined: ToolCatalogEntry[] = [],
) {
root.render(
<QueryClientProvider client={client}>
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
</QueryClientProvider>,
);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset();
getTestAgentAccessMock.mockReset();
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset();
declineActionRequestMock.mockReset();
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
// Default ask-first polls report the request still waiting on approval.
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "waiting",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: null,
durationMs: null,
});
window.sessionStorage.clear();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
window.sessionStorage.clear();
});
describe("TestPanel", () => {
it("shows a lightweight agent-loading state before requesting permissions", async () => {
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
await act(async () => renderPanel());
expect(container.textContent).toContain("Loading agents…");
expect(container.querySelector(".animate-spin")).toBeTruthy();
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
expect(getTestAgentAccessMock).not.toHaveBeenCalled();
});
it("loads permissions only for the selected agent and reuses the cached summary", async () => {
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
});
it("renders the test hierarchy and grouped actions with access badges", async () => {
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Test an action");
expect(container.textContent).toContain("Agent");
expect(container.textContent).toContain("ClaudeCoder");
expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
expect(container.textContent).toContain("Read (1)");
expect(container.textContent).toContain("Write (2)");
// Each action shows its decision badge.
expect(container.textContent).toContain("Allowed");
expect(container.textContent).toContain("Ask first");
expect(container.textContent).toContain("Off");
expect(container.querySelector(".bg-card")).toBeNull();
});
it("defaults to the highest-ranked accessible agent", async () => {
listTestAgentsMock.mockResolvedValue({
agents: [
agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
],
});
await act(async () => renderPanel());
await flushReact();
expect(container.textContent).toContain("Root agent");
expect(container.textContent).not.toContain("A report");
});
it("shows the empty state when there are no actions", async () => {
await act(async () => renderPanel([]));
await flushReact();
expect(container.textContent).toContain("Nothing to test yet");
expect(container.textContent).toContain("Go to Permissions");
});
it("renders an allowed result panel with a row-count headline after a successful run", async () => {
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-1",
result: [
{ name: "Acme", stage: "Demo" },
{ name: "Globex", stage: "Trial" },
],
});
await act(async () => renderPanel());
await flushReact();
// Expand the allowed (read) action.
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
// Fill the required field then Run.
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
agentId: "agent-claude",
toolName: "read_sheet",
parameters: { spreadsheetId: "sheet-123" },
});
expect(container.textContent).toContain("Worked. 2 rows came back.");
expect(container.textContent).toContain("Ran as ClaudeCoder");
expect(container.textContent).toContain("Preview");
});
it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => {
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-credential",
error: { message: "The connection must be reconnected.", reasonCode },
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("After reconnecting, run this action again.");
expect(container.textContent).not.toContain("Adjust the input above");
expect(container.textContent).not.toContain("Check the field formats");
expect(container.textContent).toContain(reasonCode === "grant_credential_invalid"
? "Ask the connection owner to reconnect"
: "allow the permissions required for this action");
});
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
// The gateway let the call through (decision:"allowed") but the upstream MCP
// tool failed at the tool layer — the envelope carries isError + an error.
runTestCallMock.mockResolvedValue({
decision: "allowed",
invocationId: "inv-err",
result: {
content: "Invalid arguments: range is required",
data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
error: "MCP tool returned an error result",
},
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("It didn't work.");
expect(container.textContent).not.toContain("Worked.");
// The actual MCP message wins over the generic gateway wrapper string.
expect(container.textContent).toContain("Invalid arguments: range is required");
});
it("renders the ask-first card linking to Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("Cancel this request");
// "Where" is populated from the polled action-request snapshot.
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Waiting ·");
const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
});
it("restores the ask-first card after leaving and returning to the Test tab", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Sent for your OK.");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(runTestCallMock).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("Sent for your OK.");
expect(container.textContent).toContain("spreadsheetId: sheet-123");
expect(container.textContent).toContain("Cancel this request");
});
it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "running",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Approved · running");
// While running we don't offer cancel — the call is already on its way.
expect(container.textContent).not.toContain("Cancel this request");
});
it("mutates the ask-first card into the allowed result once approved and done", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "done",
parameters: { spreadsheetId: "sheet-123" },
result: [{ name: "Acme", stage: "Demo" }],
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: 1200,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
// No longer the pending card — the real response is shown without re-running.
expect(container.textContent).not.toContain("Sent for your OK.");
expect(container.textContent).toContain("Worked.");
expect(container.textContent).toContain("1.2s");
});
it("shows the denied status when an ask-first call is declined in Review", async () => {
runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
invocationId: "inv-2",
phase: "denied",
parameters: { spreadsheetId: "sheet-123" },
requestedAt: "2026-06-18T00:00:00.000Z",
resolvedAt: "2026-06-18T00:00:05.000Z",
durationMs: null,
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
await act(async () => trigger!.click());
await flushReact();
await fillFormField("sheet-123");
await clickByText("Run");
await settle();
expect(container.textContent).toContain("Denied — see Review for why");
expect(container.textContent).not.toContain("Cancel this request");
});
it("explains an off action and links to Permissions without calling the API", async () => {
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
expect(permLink).toBeTruthy();
// No Run button is offered for an off action.
expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
expect(runTestCallMock).not.toHaveBeenCalled();
});
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
getTestAgentAccessMock.mockResolvedValue({
access: {
...agent().effectiveAccess,
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
});
await act(async () => renderPanel());
await flushReact();
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("Last changed by Dotta");
});
it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
const quarantinedEntry = catalogEntry({
id: "catalog-new",
toolName: "rename_sheet",
title: "Rename a sheet",
description: "Change a sheet's name.",
isReadOnly: false,
isWrite: true,
riskLevel: "write",
status: "quarantined",
});
await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
await flushReact();
expect(container.textContent).toContain("New (1)");
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
expect(trigger).toBeTruthy();
await act(async () => trigger!.click());
await flushReact();
expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
expect(runTestCallMock).not.toHaveBeenCalled();
});
});
describe("errorHints", () => {
it("maps known reason codes to tailored hints", () => {
expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
});
it("returns the locked generic fallback for an unknown error", () => {
expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
});
});
@@ -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>