mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
## 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>
170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
// @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");
|
|
});
|
|
});
|