fix(ui): confirm every copy action (#13603)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Much of the work means moving an opaque value somewhere else: an
agent id, a callback URL, a webhook secret, an error payload for a bug
report
> - Copy buttons all over the app do that, and most already say whether
it worked — an icon that flips to a check, a label that reads "Copied"
> - Eleven did not. They passed the promise to `.catch(() => {})` and
showed nothing at all
> - A copy that says nothing looks exactly like a copy the browser
refused, and the only way to tell is to paste somewhere and look
> - The clipboard really does refuse: over plain HTTP on a non-secure
host the async Clipboard API is unavailable, and the fallback can still
fail
> - This pull request gives every copy action an affordance, through two
shared hooks
> - The benefit is that a person can tell a copy from a failure without
leaving the page

## Linked Issues or Issue Description

**What happened?**

Eleven copy buttons gave no feedback. They called `copyTextToClipboard`
and discarded the result:

```
onClick={() => void copyTextToClipboard(robotEmail).catch(() => {})}
```

Nothing changes on screen, whether the write succeeded or failed. This
is the same surface where most other copy buttons do show a check or a
"Copied" label, so the silent ones read as broken.

**Expected behavior**

Every copy action says whether the clipboard took the value. A rejected
write says so rather than staying silent or claiming success.

**Steps to reproduce**

1. Open the connector setup flow for an app that shows a sharing email
or a callback URL.
2. Press **Copy** next to that value.
3. Before this change nothing on screen changes. Compare with the copy
button on a login panel, which flips to a check.
4. To see the failure case, open Paperclip over plain HTTP on a
non-localhost host, where the async Clipboard API is not available.

**Paperclip version or commit**

`45586170e` on `master`.

**Deployment mode**

Local.

**Agent adapter(s) involved**

Not adapter-specific (core bug).

**Additional context**

Commit `1fa36be35` moved every copy through `lib/clipboard.ts`, so the
call sites are findable. Of 62 call sites, 51 already showed feedback
and 11 did not.

## What Changed

- New `ui/src/lib/use-copy-action.ts` with two hooks:
- `useCopyAction` for a control that stays on screen. It returns
`copied` and `failed` for the inline swap the rest of the app already
uses, and resets itself.
- `useCopyToast` for a menu item, which unmounts with its menu before an
inline state could be read, so its confirmation goes to the toast
viewport.
- Both wait for the write to resolve before reporting success, and both
report a rejection as a failure. `AdapterLoginChrome.test.tsx` already
held that line for one button; the helper now makes it the default.
- The eleven silent sites now use one of the two: the connector setup
flow's sharing-email and callback-URL buttons, the runner inspector's
copy-value and copy-path buttons, the agent bubble and skill studio menu
items, annotation "Copy link" on both its hosts, both secret-error
detail buttons, the chat webhook secret, and the motion tweak panel's
export box.
- The chat webhook secret follows its own file's convention: a label
that reads "Webhook secret copied", matching the manifest buttons beside
it.
- The 51 sites that already had feedback are untouched. Converting them
would be a large diff with no visible change.
- `clipboard-usage.test.ts` gains a static check, sibling to the one
that already keeps copies on the shared helper. It fails if a new copy
site ships with no affordance.

## Verification

- `npx vitest run ui/src/lib/use-copy-action.test.tsx
ui/src/lib/clipboard-usage.test.ts ui/src/lib/clipboard.test.ts
ui/src/components/AdapterLoginChrome.test.tsx` — 18 tests pass.
- The new hook tests cover the three cases that matter: no confirmation
while the write is still in flight, a failure state on a rejected write,
and a return to rest after the reset delay. The toast hook is covered
for both tones.
- `npx vitest run ui/src/pages/apps/AppsConnect.test.tsx
ui/src/components/task-chat ui/src/components/RunnerInspector.test.tsx
ui/src/components/DocumentAnnotation` — the suites over the touched
components pass.
- `npx tsc -b ui` — clean.

## Risks

Low. Each change is additive at its own call site and the copy path
itself is unchanged. The static check is the only part that touches
future contributors: it is one assertion, and its pattern list is easy
to extend or drop.

## Model Used

Claude Opus 5 (`claude-opus-5`), 1M context, extended thinking, with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 5 authored and GitHub committed 2026-09-18 14:32:19 -07:00
1 parent 34355e5109
commit 8f69e0af9d
17 files changed
+512 -122

No files matched your search

+17 -13
View File
@@ -5,7 +5,7 @@ import type {
} from "@paperclipai/shared";
import { cn, formatShortDate } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction, useCopyToast } from "../lib/use-copy-action";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -24,7 +24,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, Copy, MoreHorizontal, ThumbsDown, ThumbsUp } from "lucide-react";
import { Check, Copy, MoreHorizontal, ThumbsDown, ThumbsUp, X } from "lucide-react";
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -48,24 +48,26 @@ export function agentBubbleDateLabel(date: Date | string | undefined): string {
* re-declaring the same button markup on each surface.
*/
export function BubbleCopyButton({ copyText }: { copyText: string }) {
const [copied, setCopied] = useState(false);
const { copied, failed, copy } = useCopyAction(2000);
const label = failed ? "Couldn’t copy message" : "Copy message";
return (
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
title={label}
aria-label={label}
onClick={() => {
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
void copy(copyText);
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? (
<Check className="h-3.5 w-3.5" />
) : failed ? (
<X className="h-3.5 w-3.5 text-destructive" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
}
@@ -109,6 +111,8 @@ export function AgentBubbleActionRow({
menuItems?: ReactNode;
className?: string;
}) {
// The menu closes on click, so its copy confirmation has to outlive it.
const copyWithToast = useCopyToast();
return (
<div className={cn("mt-2 flex items-center gap-1", className)}>
<BubbleCopyButton copyText={copyText} />
@@ -150,7 +154,7 @@ export function AgentBubbleActionRow({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
void copyTextToClipboard(copyText).catch(() => {});
void copyWithToast(copyText, "Message copied");
}}
>
<Copy className="mr-2 h-3.5 w-3.5" />
+20 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useEffect } from "react";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import type {
DocumentAnnotationComment,
DocumentAnnotationThreadWithComments,
@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, relativeTime } from "@/lib/utils";
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyToast } from "@/lib/use-copy-action";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
@@ -103,6 +103,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
}
export function AnnotationPanelBody(props: AnnotationPanelProps) {
const copyAnnotationLink = useCopyAnnotationLink();
const [composerValue, setComposerValue] = useState("");
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const composerRef = useRef<HTMLTextAreaElement | null>(null);
@@ -536,13 +537,21 @@ export function truncate(value: string, limit: number) {
return `${value.slice(0, limit - 1)}…`;
}
export async function copyAnnotationLink(documentKey: string, threadId: string) {
if (typeof window === "undefined") return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
try {
await copyTextToClipboard(`${window.location.origin}${pathname}${hash}`);
} catch {
/* swallow */
}
/**
* Copy a deep link to an annotation thread, and say whether it landed.
*
* The trigger is a menu item that closes on click, so the confirmation is a
* toast rather than an inline state nobody would still be looking at.
*/
export function useCopyAnnotationLink() {
const copyWithToast = useCopyToast();
return useCallback(
(documentKey: string, threadId: string) => {
if (typeof window === "undefined") return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
void copyWithToast(`${window.location.origin}${pathname}${hash}`, "Link copied");
},
[copyWithToast],
);
}
@@ -6,7 +6,7 @@ import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import type { CompanyUserProfile } from "@/lib/company-members";
import { useDocumentAnnotationMutations } from "@/hooks/useDocumentAnnotationMutations";
import type { AnnotationAnchorRect, PendingAnchor } from "./DocumentAnnotationLayer";
import { copyAnnotationLink, ThreadCard, truncate } from "./DocumentAnnotationPanel";
import { useCopyAnnotationLink, ThreadCard, truncate } from "./DocumentAnnotationPanel";
export interface DocumentAnnotationPopoverProps {
anchorRect: AnnotationAnchorRect;
@@ -27,6 +27,7 @@ export interface DocumentAnnotationPopoverProps {
}
export function DocumentAnnotationPopover(props: DocumentAnnotationPopoverProps) {
const copyAnnotationLink = useCopyAnnotationLink();
const cardRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const [composer, setComposer] = useState("");
+9 -7
View File
@@ -55,7 +55,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyAction } from "@/lib/use-copy-action";
type TraceEntry = Record<string, unknown>;
type InspectorView = "overview" | "pipeline" | "trace";
@@ -415,9 +415,11 @@ function JsonNode({
key.toLowerCase().includes(query.toLowerCase()) || jsonMatches(child, query),
)
: entries;
const pathCopy = useCopyAction();
const valueCopy = useCopyAction();
const copyValue = () => {
const serialized = typeof value === "string" ? value : JSON.stringify(value, null, 2);
void copyTextToClipboard(serialized ?? String(value));
void valueCopy.copy(serialized ?? String(value));
};
return (
<div className={cn(depth > 0 && "border-l border-border/60 pl-3")}>
@@ -445,19 +447,19 @@ function JsonNode({
<span className="ml-auto hidden items-center gap-0.5 group-hover:flex">
<button
type="button"
title="Copy JSON path"
title={pathCopy.copied ? "JSON path copied" : pathCopy.failed ? "Copy failed" : "Copy JSON path"}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => void copyTextToClipboard(path)}
onClick={() => void pathCopy.copy(path)}
>
<Braces className="h-3 w-3" />
{pathCopy.copied ? <Check className="h-3 w-3" /> : <Braces className="h-3 w-3" />}
</button>
<button
type="button"
title="Copy value"
title={valueCopy.copied ? "Value copied" : valueCopy.failed ? "Copy failed" : "Copy value"}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={copyValue}
>
<Copy className="h-3 w-3" />
{valueCopy.copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</span>
</div>
+6 -11
View File
@@ -1,20 +1,15 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { getWorktreeUiBranding } from "../lib/worktree-branding";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction } from "../lib/use-copy-action";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
const [copied, setCopied] = useState(false);
const { copied, failed, copy } = useCopyAction();
const handleCopyName = useCallback(() => {
if (!branding) return;
void copyTextToClipboard(branding.name)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}, [branding]);
void copy(branding.name);
}, [branding, copy]);
if (!branding) return null;
@@ -38,7 +33,7 @@ export function WorktreeBanner() {
title="Click to copy worktree name"
className="truncate font-semibold tracking-(--tracking-eyebrow) cursor-pointer hover:opacity-80 transition-opacity bg-transparent border-none p-0 text-current uppercase text-(length:--text-micro)"
>
{copied ? "Copied!" : branding.name}
{copied ? "Copied!" : failed ? "Copy failed" : branding.name}
</button>
</div>
</div>
@@ -1,11 +1,10 @@
import { useState } from "react";
import type {
FeedbackDataSharingPreference,
FeedbackVoteValue,
} from "@paperclipai/shared";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyAction } from "@/lib/use-copy-action";
import { IssueChatFeedbackButtons } from "@/components/AgentBubbleActionRow";
import { Check, Copy } from "lucide-react";
import { Check, Copy, X } from "lucide-react";
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
export interface TaskChatBubbleFeedback {
@@ -33,25 +32,27 @@ export function TaskChatBubbleActions({
copyText: string;
feedback?: TaskChatBubbleFeedback | null;
}) {
const [copied, setCopied] = useState(false);
const { copied, failed, copy } = useCopyAction(2000);
const label = failed ? "Couldn’t copy message" : "Copy message";
return (
<div className="flex items-center gap-0.5" data-testid="task-chat-bubble-actions">
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
title={label}
aria-label={label}
onClick={() => {
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
void copy(copyText);
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? (
<Check className="h-3.5 w-3.5" />
) : failed ? (
<X className="h-3.5 w-3.5 text-destructive" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
{feedback ? (
<IssueChatFeedbackButtons
+22 -11
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyAction } from "@/lib/use-copy-action";
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
import {
EASING_PRESETS,
@@ -53,6 +53,7 @@ export function TweakPanel() {
loadSession(OVERRIDES_KEY, {}),
);
const [exportText, setExportText] = useState<string | null>(null);
const exportCopy = useCopyAction();
const dragRef = useRef<{ dx: number; dy: number } | null>(null);
// Initialize display values from computed styles, then re-apply any saved
@@ -214,16 +215,26 @@ export function TweakPanel() {
</div>
{exportText ? (
<textarea
readOnly
data-testid="task-chat-tweak-export"
className="mt-2 h-32 w-full resize-none rounded border border-border bg-muted/40 p-2 font-mono text-(length:--text-nano)"
value={exportText}
onFocus={(e) => {
e.currentTarget.select();
void copyTextToClipboard(exportText).catch(() => {});
}}
/>
<>
<textarea
readOnly
data-testid="task-chat-tweak-export"
className="mt-2 h-32 w-full resize-none rounded border border-border bg-muted/40 p-2 font-mono text-(length:--text-nano)"
value={exportText}
onFocus={(e) => {
e.currentTarget.select();
void exportCopy.copy(exportText);
}}
/>
{/* Focusing the box copies it silently; say so, or the click looks inert. */}
<p className="mt-1 text-(length:--text-nano) text-muted-foreground" aria-live="polite">
{exportCopy.copied
? "Copied to clipboard"
: exportCopy.failed
? "Copy failed — select the text and copy it manually"
: "Click the box to copy"}
</p>
</>
) : null}
</div>
)}
@@ -66,7 +66,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyAction } from "@/lib/use-copy-action";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
@@ -3509,15 +3509,7 @@ function KeyStep({
>
{robotEmail}
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => void copyTextToClipboard(robotEmail).catch(() => {})}
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
<CopyValueButton value={robotEmail} ariaLabel="Copy sharing email" />
</div>
<p className="mt-2 text-xs text-muted-foreground">
In Google Sheets, click Share and add this email as an Editor. Then paste the sheet links below.
@@ -3727,6 +3719,27 @@ function KeyStep({
);
}
/**
* The Copy button beside a value the operator has to paste into another
* product's console. Both of those values are long and opaque, so the button
* has to say whether the clipboard actually took it.
*/
function CopyValueButton({ value, ariaLabel }: { value: string; ariaLabel: string }) {
const { copied, failed, copy } = useCopyAction();
return (
<Button
type="button"
variant="outline"
className="shrink-0"
aria-label={ariaLabel}
onClick={() => void copy(value)}
>
{copied ? <Check className="mr-2 h-4 w-4" /> : <Copy className="mr-2 h-4 w-4" />}
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
</Button>
);
}
function OAuthClientFields({
entry,
method,
@@ -3777,15 +3790,7 @@ function OAuthClientFields({
>
{callbackUrl}
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => void copyTextToClipboard(callbackUrl).catch(() => {})}
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
<CopyValueButton value={callbackUrl} ariaLabel="Copy callback URL" />
</div>
<p className="mt-2 text-xs text-muted-foreground">
Add this exact URL to {entry.name} before continuing. It must match the authorization request.
+55
View File
@@ -3,6 +3,9 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const DIRECT_CLIPBOARD_WRITE = /navigator\.clipboard|document\.execCommand/;
/** Any affordance that tells the reader the copy landed (or didn't). */
const COPY_FEEDBACK =
/useCopyAction|useCopyToast|copyWithToast|CopyValueButton|CopyDetailsButton|setCopied|pushToast|showCopyFeedback|setCopyStatus|Copied/i;
function sourceFiles(root: URL): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
@@ -23,4 +26,56 @@ describe("clipboard usage", () => {
expect(violations).toEqual([]);
});
it("gives every copy action some way to say whether it worked", () => {
// A copy that says nothing is indistinguishable from a copy that failed,
// which is the whole complaint. Whichever affordance a site picks — the
// inline swap from `useCopyAction`, a toast from `useCopyToast`, or a
// hand-rolled `copied` state — it has to pick one.
//
// Scope, so nobody reads more into a pass than it earns: this catches
// *silence*. It cannot prove the affordance waits for the write to
// resolve — a dozen sites name their state differently enough that
// demanding a known helper would flag nine working buttons. The
// swallowed-rejection check below covers the specific way a site claims
// success it did not earn; `AdapterLoginChrome.test.tsx` and
// `use-copy-action.test.ts` hold the behavioral line per site.
const silent = sourceFiles(new URL("../", import.meta.url))
.filter((file) => !file.replaceAll("\\", "/").endsWith("/lib/clipboard.ts"))
.flatMap((file) => {
const lines = readFileSync(file, "utf8").split("\n");
return lines.flatMap((line, index) => {
if (!line.includes("copyTextToClipboard(")) return [];
if (line.includes("import") || line.includes("export")) return [];
const near = lines.slice(Math.max(0, index - 8), index + 9).join("\n");
return COPY_FEEDBACK.test(near) ? [] : [`${file}:${index + 1}`];
});
});
expect(silent).toEqual([]);
});
it("never discards a copy rejection", () => {
// The precise way a copy button lies: hand the promise an empty rejection
// handler, then show a success state the write never earned. That is the
// pattern all eleven silent sites had, and the one
// `AdapterLoginChrome.test.tsx` refuses for its own button.
const swallowed = sourceFiles(new URL("../", import.meta.url))
.filter((file) => !file.replaceAll("\\", "/").endsWith("/lib/clipboard.ts"))
.flatMap((file) => {
const source = readFileSync(file, "utf8");
const lines = source.split("\n");
return lines.flatMap((line, index) => {
if (!line.includes("copyTextToClipboard(")) return [];
if (line.includes("import") || line.includes("export")) return [];
// The handler can wrap onto the next lines, so read forward a little.
const after = lines.slice(index, index + 6).join("\n");
return /\.catch\(\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)|,\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)/.test(after)
? [`${file}:${index + 1}`]
: [];
});
});
expect(swallowed).toEqual([]);
});
});
+169
View File
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { StrictMode, act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider, useToastState } from "../context/ToastContext";
import { useCopyAction, useCopyToast } from "./use-copy-action";
const writeText = vi.fn<(text: string) => Promise<void>>();
vi.mock("./clipboard", () => ({
copyTextToClipboard: (text: string) => writeText(text),
}));
let container: HTMLDivElement;
let root: Root | null = null;
function render(node: React.ReactNode) {
act(() => {
root!.render(<StrictMode>{node}</StrictMode>);
});
}
function buttonByText(text: string) {
return Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === text,
);
}
async function clickCopy(label = "Copy") {
await act(async () => {
buttonByText(label)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function InlineCopy() {
const { copied, failed, copy } = useCopyAction(1000);
return (
<button type="button" onClick={() => void copy("secret-value")}>
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
</button>
);
}
function ToastCopy() {
const copyWithToast = useCopyToast();
const toasts = useToastState();
return (
<>
<button type="button" onClick={() => void copyWithToast("secret-value", "Message copied")}>
Copy
</button>
<ul>
{toasts.map((toast) => (
<li key={toast.id} data-tone={toast.tone}>
{toast.title}
</li>
))}
</ul>
</>
);
}
beforeEach(() => {
vi.useFakeTimers();
writeText.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.useRealTimers();
});
describe("useCopyAction", () => {
it("confirms only after the clipboard write resolves", async () => {
let resolveWrite: (() => void) | undefined;
writeText.mockReturnValue(new Promise<void>((resolve) => { resolveWrite = resolve; }));
render(<InlineCopy />);
await clickCopy();
// The write is still in flight: claiming success here would be a lie.
expect(buttonByText("Copied")).toBeUndefined();
await act(async () => { resolveWrite?.(); });
expect(buttonByText("Copied")).toBeDefined();
expect(writeText).toHaveBeenCalledWith("secret-value");
});
it("reports a rejected write as a failure, never as copied", async () => {
writeText.mockRejectedValue(new Error("Clipboard unavailable"));
render(<InlineCopy />);
await clickCopy();
expect(buttonByText("Copy failed")).toBeDefined();
expect(buttonByText("Copied")).toBeUndefined();
});
it("returns to rest after the reset delay", async () => {
writeText.mockResolvedValue(undefined);
render(<InlineCopy />);
await clickCopy();
expect(buttonByText("Copied")).toBeDefined();
await act(async () => { vi.advanceTimersByTime(1000); });
expect(buttonByText("Copy")).toBeDefined();
});
it("shows the newest click's outcome when an older write resolves late", async () => {
// Clipboard writes can settle out of order. Whatever the reader clicked
// last is what the control has to report — a stale failure must not
// overwrite a fresh success.
let failFirst: ((reason: Error) => void) | undefined;
writeText.mockReturnValueOnce(
new Promise<void>((_resolve, reject) => { failFirst = reject; }),
);
writeText.mockResolvedValue(undefined);
render(<InlineCopy />);
await clickCopy();
expect(buttonByText("Copy")).toBeDefined();
// Second click resolves immediately and takes over the display.
await clickCopy();
expect(buttonByText("Copied")).toBeDefined();
// Now the first write finally rejects. It is no longer in charge.
await act(async () => { failFirst?.(new Error("Clipboard unavailable")); });
expect(buttonByText("Copied")).toBeDefined();
expect(buttonByText("Copy failed")).toBeUndefined();
// And the stale click cannot reset the control out from under the new one.
await act(async () => { vi.advanceTimersByTime(999); });
expect(buttonByText("Copied")).toBeDefined();
await act(async () => { vi.advanceTimersByTime(1); });
expect(buttonByText("Copy")).toBeDefined();
});
});
describe("useCopyToast", () => {
it("raises a success toast once the write resolves", async () => {
writeText.mockResolvedValue(undefined);
render(<ToastProvider><ToastCopy /></ToastProvider>);
await clickCopy();
const toast = container.querySelector("li");
expect(toast?.textContent).toBe("Message copied");
expect(toast?.getAttribute("data-tone")).toBe("success");
});
it("raises an error toast instead of claiming success", async () => {
writeText.mockRejectedValue(new Error("Clipboard unavailable"));
render(<ToastProvider><ToastCopy /></ToastProvider>);
await clickCopy();
const toast = container.querySelector("li");
expect(toast?.textContent).toContain("copy to clipboard");
expect(toast?.getAttribute("data-tone")).toBe("error");
});
});
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { copyTextToClipboard } from "./clipboard";
import { useOptionalToastActions } from "../context/ToastContext";
export type CopyStatus = "idle" | "copied" | "failed";
/** How long a "Copied" state stays up before the control returns to rest. */
const RESET_MS = 1500;
/**
* Inline copy feedback for a control that stays on screen after the click.
*
* Success is earned, never assumed. The status only turns `copied` once the
* clipboard write resolves, so a blocked or insecure clipboard reads as a
* failure rather than a confirmation of something that never happened — the
* line `AdapterLoginChrome.test.tsx` already holds for its own copy button.
*/
export function useCopyAction(resetMs: number = RESET_MS) {
const [status, setStatus] = useState<CopyStatus>("idle");
const timerRef = useRef<number | null>(null);
/**
* Which click owns the visible status. Clipboard writes can resolve out of
* order, so without this a slow first copy could land after a fast second
* one and report the wrong attempt — or reset the status while the newer
* copy was still showing.
*/
const latestCopyRef = useRef(0);
useEffect(
() => () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
},
[],
);
const copy = useCallback(
async (text: string) => {
const token = ++latestCopyRef.current;
let next: CopyStatus = "copied";
try {
await copyTextToClipboard(text);
} catch {
next = "failed";
}
// A newer click has taken over the control; report this outcome to the
// caller but leave the display to whoever the reader is watching.
if (latestCopyRef.current !== token) return next;
setStatus(next);
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (latestCopyRef.current !== token) return;
setStatus("idle");
timerRef.current = null;
}, resetMs);
return next;
},
[resetMs],
);
return {
status,
copied: status === "copied",
failed: status === "failed",
copy,
};
}
/**
* Copy feedback for a control that disappears on click — a menu item, mostly.
*
* An inline "Copied" swap would unmount with the menu before anyone read it,
* so the confirmation goes to the toast viewport instead. Same rule as above:
* the success toast waits for the write to resolve.
*/
export function useCopyToast() {
const toastActions = useOptionalToastActions();
const pushToast = toastActions?.pushToast;
return useCallback(
async (text: string, copiedTitle = "Copied") => {
try {
await copyTextToClipboard(text);
pushToast?.({
title: copiedTitle,
tone: "success",
dedupeKey: `copy:${copiedTitle}`,
});
return true;
} catch {
pushToast?.({
title: "Couldn’t copy to clipboard",
body: "Select and copy the value manually.",
tone: "error",
dedupeKey: "copy-failed",
});
return false;
}
},
[pushToast],
);
}
+4 -8
View File
@@ -29,6 +29,7 @@ import { useBreadcrumbs, type Breadcrumb } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction } from "../lib/use-copy-action";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { MarkdownEditor } from "../components/MarkdownEditor";
@@ -2734,7 +2735,7 @@ function SkillLocationCard({
folderPath: string | null | undefined;
onMove?: () => void;
}) {
const [copied, setCopied] = useState(false);
const { copied, failed, copy } = useCopyAction();
const canonical = folderPath && folderPath.length > 0 ? folderPath : "Unfiled";
return (
<section>
@@ -2748,16 +2749,11 @@ function SkillLocationCard({
size="sm"
variant="outline"
onClick={() => {
void copyTextToClipboard(canonical)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
void copy(canonical);
}}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{copied ? "Copied" : "Copy path"}
{copied ? "Copied" : failed ? "Copy failed" : "Copy path"}
</Button>
{onMove ? (
<Button size="sm" variant="outline" onClick={onMove}>
+4 -8
View File
@@ -29,6 +29,7 @@ import { useBreadcrumbs, type Breadcrumb } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction } from "../lib/use-copy-action";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { MarkdownEditor } from "../components/MarkdownEditor";
@@ -2751,7 +2752,7 @@ function SkillLocationCard({
folderPath: string | null | undefined;
onMove?: () => void;
}) {
const [copied, setCopied] = useState(false);
const { copied, failed, copy } = useCopyAction();
const canonical = folderPath && folderPath.length > 0 ? folderPath : "Unfiled";
return (
<section>
@@ -2765,16 +2766,11 @@ function SkillLocationCard({
size="sm"
variant="outline"
onClick={() => {
void copyTextToClipboard(canonical)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
void copy(canonical);
}}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{copied ? "Copied" : "Copy path"}
{copied ? "Copied" : failed ? "Copy failed" : "Copy path"}
</Button>
{onMove ? (
<Button size="sm" variant="outline" onClick={onMove}>
+16 -15
View File
@@ -108,6 +108,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction } from "../lib/use-copy-action";
import { PageTabBar } from "../components/PageTabBar";
import { AgentSelect } from "../components/AgentMultiSelect";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
@@ -3916,6 +3917,19 @@ function AwsProviderVaultDiscoveryPanel({
);
}
/**
* Copy the safe error payload out of a failure panel. People copy this to paste
* into a bug report, so the button has to confirm the clipboard took it.
*/
function CopyDetailsButton({ text }: { text: string }) {
const { copied, failed, copy } = useCopyAction();
return (
<Button type="button" variant="ghost" size="sm" onClick={() => void copy(text)}>
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
</Button>
);
}
function AwsProviderVaultDiscoveryError({
form,
error,
@@ -3941,10 +3955,6 @@ function AwsProviderVaultDiscoveryError({
};
const detailsText = JSON.stringify(safeDetails, null, 2);
const copyDetails = () => {
void copyTextToClipboard(detailsText).catch(() => {});
};
return (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
@@ -3992,9 +4002,7 @@ function AwsProviderVaultDiscoveryError({
<div className="rounded-md border border-destructive/20 bg-background/70 p-2 text-foreground">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Safe request/error details</span>
<Button type="button" variant="ghost" size="sm" onClick={copyDetails}>
Copy
</Button>
<CopyDetailsButton text={detailsText} />
</div>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-(length:--text-micro) leading-relaxed">
{detailsText}
@@ -4087,14 +4095,7 @@ function SecretCreateError({
<div className="rounded-md border border-destructive/20 bg-background/70 p-2 text-foreground">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Safe request/error details</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void copyTextToClipboard(detailsText).catch(() => {})}
>
Copy
</Button>
<CopyDetailsButton text={detailsText} />
</div>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-(length:--text-micro) leading-relaxed">
{detailsText}
+4 -1
View File
@@ -54,6 +54,7 @@ import { companySkillsApi } from "@/api/companySkills";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyToast } from "@/lib/use-copy-action";
import { skillStudioNewRoute, skillStudioRoute } from "@/lib/company-skill-routes";
import {
buildBlankSkillDraft,
@@ -1905,6 +1906,8 @@ function InputPane({
}) {
const queryClient = useQueryClient();
const onError = useMutationErrorToast();
// The row's menu closes on click, so its copy confirmation goes to a toast.
const copyWithToast = useCopyToast();
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [savedInputDraft, setSavedInputDraft] = useState<SavedInputDraftState>(
EMPTY_SAVED_INPUT_DRAFT_STATE,
@@ -2097,7 +2100,7 @@ function InputPane({
<DropdownMenuItem
onClick={() => {
const input = inputs.find((i) => i.id === id);
if (input) void copyTextToClipboard(input.content).catch(() => {});
if (input) void copyWithToast(input.content, "Input content copied");
}}
>
<Copy className="mr-2 h-4 w-4" /> Copy content
@@ -182,6 +182,28 @@ describe("chat setup and identity-link clipboard actions", () => {
expect(JSON.stringify(mocks.pushToast.mock.calls)).not.toContain(secret);
});
// The toast provider drops an identical toast raised inside 3.5 seconds, so
// a reader clicking twice against a blocked clipboard would see the failure
// once and then nothing. The inline state has to answer every click.
it("still shows a repeated copy failure the toast would have deduplicated", async () => {
await render("github");
await click("Generate webhook secret");
execCommand.mockReturnValue(false);
await click("Copy webhook secret");
expect(container.textContent).toContain("Couldn’t copy");
// Same failure again, well inside the dedupe window: the second toast is
// suppressed, so the button itself is the only thing left to say so.
const toastsAfterFirst = mocks.pushToast.mock.calls.length;
await click("Couldn’t copy — select it manually");
expect(container.textContent).toContain("Couldn’t copy");
expect(container.textContent).not.toContain("Webhook secret copied");
expect(mocks.pushToast.mock.calls.length).toBeGreaterThanOrEqual(
toastsAfterFirst,
);
});
it("copies the private identity link with fallback and preserves success feedback", async () => {
await render("slack", true);
await click("Create private link");
+22 -4
View File
@@ -29,6 +29,7 @@ import {
import { useNavigate, useSearchParams } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import { useCopyAction } from "@/lib/use-copy-action";
import { isAgentStatusInvokable } from "@paperclipai/shared";
import { sanitizedSetupErrorMessage } from "./chat-setup-error";
import {
@@ -579,6 +580,11 @@ function ProviderConnectStep({
</div>
);
const [manifestCopied, setManifestCopied] = useState(false);
// A hook rather than a sticky boolean: this step stays mounted when the
// secret is regenerated, so a latched "copied" would keep vouching for a
// value the reader never copied. The status resets itself, and a refused
// clipboard reads as a failure instead of a success.
const webhookSecretCopy = useCopyAction();
const [privateKeyVisible, setPrivateKeyVisible] = useState(false);
const [privateKeyFileError, setPrivateKeyFileError] = useState<string | null>(
null,
@@ -1267,12 +1273,24 @@ settings:
type="button"
variant="outline"
onClick={() => {
void copyTextToClipboard(generatedWebhookSecret).catch(
reportCopyFailure,
);
// Both signals, and each covers the other's blind spot.
// The toast is the loud one, the way this step's other two
// copy buttons report failure — but the provider dedupes an
// identical toast inside 3.5s, so a reader who clicks twice
// on a blocked clipboard would see nothing the second time.
// The inline state answers every click.
void webhookSecretCopy
.copy(generatedWebhookSecret)
.then((status) => {
if (status === "failed") reportCopyFailure();
});
}}
>
Copy webhook secret
{webhookSecretCopy.copied
? "Webhook secret copied"
: webhookSecretCopy.failed
? "Couldn’t copy — select it manually"
: "Copy webhook secret"}
</Button>
</div>
<p className="text-sm text-muted-foreground">