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

@@ -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>
);
}