mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
1 parent
34355e5109
commit
8f69e0af9d
17 files changed
+512
-122
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user