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 ? ( -