Files
PaperClipAI/ui/src/components/task-chat/TweakPanel.tsx
T
scotttongandClaude Opus 5 8f69e0af9d 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>
2026-09-18 14:32:19 -07:00

254 lines
9.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useCopyAction } from "@/lib/use-copy-action";
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
import {
EASING_PRESETS,
MOTION_TOKENS,
MOTION_TOKEN_EXPORT_FALLBACK,
MOTION_TOKEN_GROUPS,
parseCssTimeMs,
type MotionTokenDef,
} from "./motion-tokens";
const POS_KEY = "tc-tweak-pos";
const OVERRIDES_KEY = "tc-tweak-overrides";
function readComputed(name: string): string {
if (typeof document === "undefined") return "";
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function loadSession<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function saveSession(key: string, value: unknown) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* session storage unavailable — panel still works, just not persisted */
}
}
/**
* Hand-rolled motion tweak panel (dev/flag only). Floating, draggable,
* minimizable overlay that lists every motion token, writes changes live to the
* CSS custom properties (no reload), exports a paste-ready :root block, and
* persists position + overrides for the session only. No third-party GUI lib —
* one file, one dev guard, trivial to strip out later.
*/
export function TweakPanel() {
const [pos, setPos] = useState<{ x: number; y: number }>(() =>
loadSession(POS_KEY, { x: 24, y: 24 }),
);
const [minimized, setMinimized] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [overrides, setOverrides] = useState<Record<string, string>>(() =>
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
// session overrides so a reload-free session keeps its tuning.
useEffect(() => {
const initial: Record<string, string> = {};
for (const t of MOTION_TOKENS) initial[t.name] = readComputed(t.name);
for (const [name, value] of Object.entries(overrides)) {
document.documentElement.style.setProperty(name, value);
initial[name] = value;
}
setValues(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setToken = useCallback((name: string, value: string) => {
document.documentElement.style.setProperty(name, value);
setValues((v) => ({ ...v, [name]: value }));
setOverrides((o) => {
const next = { ...o, [name]: value };
saveSession(OVERRIDES_KEY, next);
return next;
});
}, []);
const resetAll = useCallback(() => {
for (const name of Object.keys(overrides)) {
document.documentElement.style.removeProperty(name);
}
setOverrides({});
saveSession(OVERRIDES_KEY, {});
const refreshed: Record<string, string> = {};
for (const t of MOTION_TOKENS) refreshed[t.name] = readComputed(t.name);
setValues(refreshed);
}, [overrides]);
const onHeaderPointerDown = useCallback(
(e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y };
},
[pos],
);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const next = { x: e.clientX - dragRef.current.dx, y: e.clientY - dragRef.current.dy };
setPos(next);
}, []);
const onHeaderPointerUp = useCallback(() => {
if (dragRef.current) {
dragRef.current = null;
saveSession(POS_KEY, pos);
}
}, [pos]);
const buildExport = useCallback(() => {
const lines = MOTION_TOKENS.map((t) => {
const val = values[t.name] || readComputed(t.name);
return ` ${t.name}: ${val};`;
});
setExportText(`:root {\n${lines.join("\n")}\n}\n`);
}, [values]);
const grouped = useMemo(() => {
const map: Record<string, MotionTokenDef[]> = {};
for (const g of MOTION_TOKEN_GROUPS) map[g] = [];
for (const t of MOTION_TOKENS) map[t.group].push(t);
return map;
}, []);
return (
<div
className="fixed z-50 w-72 select-none rounded-lg border border-border bg-card text-card-foreground shadow-lg"
style={{ left: pos.x, top: pos.y }}
data-testid="task-chat-tweak-panel"
>
<div
className="flex cursor-grab items-center gap-2 rounded-t-lg border-b border-border bg-muted/50 px-2 py-1.5 text-xs font-medium active:cursor-grabbing"
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
>
<GripHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span>Motion tweak panel</span>
<div className="ml-auto flex items-center gap-1">
<button type="button" title="Reset all" onClick={resetAll} className="rounded p-0.5 hover:bg-accent">
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
type="button"
title={minimized ? "Expand" : "Minimize"}
onClick={() => setMinimized((m) => !m)}
className="rounded p-0.5 hover:bg-accent"
>
{minimized ? <Plus className="h-3.5 w-3.5" /> : <Minus className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{minimized ? null : (
<div className="max-h-(--sz-tweak-panel-max) overflow-y-auto p-2">
{MOTION_TOKEN_GROUPS.map((group) => (
<div key={group} className="mb-3">
<p className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">{group}</p>
<div className="flex flex-col gap-2">
{grouped[group].map((t) => (
<div key={t.name} data-token={t.name} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-(length:--text-micro)">
<span className="truncate font-mono text-muted-foreground">{t.name.replace("--motion-", "")}</span>
<span className="ml-2 shrink-0 tabular-nums">
{t.kind === "time" ? `${Math.round(parseCssTimeMs(values[t.name] ?? "0"))}ms` : ""}
</span>
</div>
{t.kind === "time" ? (
<input
type="range"
aria-label={t.name}
min={t.min ?? 0}
max={t.max ?? 1000}
step={t.step ?? 10}
value={Math.round(parseCssTimeMs(values[t.name] ?? "0"))}
onChange={(e) => setToken(t.name, `${e.target.value}ms`)}
className="h-1.5 w-full"
/>
) : (
<select
aria-label={t.name}
value={values[t.name] ?? ""}
onChange={(e) => setToken(t.name, e.target.value)}
className="w-full rounded border border-border bg-background px-1 py-0.5 text-(length:--text-micro)"
>
{EASING_PRESETS.every((p) => p.value !== (values[t.name] ?? "")) && values[t.name] ? (
<option value={values[t.name]}>{values[t.name]}</option>
) : null}
{EASING_PRESETS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
)}
</div>
))}
</div>
</div>
))}
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={buildExport}
className="flex flex-1 items-center justify-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent"
>
<Copy className="h-3.5 w-3.5" />
Copy as @theme
</button>
</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 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>
)}
</div>
);
}
/**
* Build the :root export string from arbitrary token values. Exposed for the
* finish-line test that asserts export produces valid CSS. Kept in sync with
* the panel's own export.
*/
export function buildTweakExport(values: Record<string, string>): string {
const lines = MOTION_TOKENS.map((t) => ` ${t.name}: ${values[t.name] ?? MOTION_TOKEN_EXPORT_FALLBACK};`);
return `:root {\n${lines.join("\n")}\n}\n`;
}