Skip to content

Consistent "Copied" feedback on every copy button  #44

Description

@a-effort

Problem

Copy-to-clipboard buttons appear in 17 places and behave four different ways. Most give no signal that the click did anything.

Feedback today Sites
Visible "Copied!" tooltip ui/code-block.tsx:139, only when a copiedLabel is passed (just PromptPreviewResult and PromptSnippetTabs)
Icon swap to Check + sr-only text servers/ServersTable.tsx:129
sr-only text only, no visible change tools/ToolForm.tsx:149 (2 buttons: input schema, output schema)
Nothing ui/copy-value.tsx:35 (rendered at 9 call sites), tools/ToolsTable.tsx:103,124, tools/ToolSchemaDialog.tsx:45, resources/ResourcesTable.tsx:90,111, gateways/VirtualServerDetailsPanel.tsx:595,611, prompts/PromptDefinitionTable.tsx:104, tokens/TokenCreatedDialog.tsx:74, servers/MCPServerDetailsPanel.tsx:498,514, servers/TestConnectionPanel.tsx:448

Two silent ones stand out. TokenCreatedDialog copies an API token shown exactly once and never again, so it is the click where "did that work?" costs the most. CopyValue is the shared detail-panel primitive, so fixing it alone covers 9 sites.

There is also duplicated state: CodeBlock, ServersTable, and ToolForm each hand-roll a copied boolean plus setTimeout, and two of the three clean up on unmount. ToolForm leaks its timer and has no .catch.

Approach

Split behavior from presentation:

  1. src/hooks/useCopyToClipboard.ts owns the write, transient state, timeout, and unmount cleanup.
  2. src/components/ui/copy-button.tsx owns the icon swap, tooltip, localized labels, and screen-reader announcement.

CopyValue and CodeBlock then delegate to CopyButton, the 12 inline <Button onClick={() => copyToClipboard(x)}> sites get replaced, and ServersTable / ToolForm drop their hand-rolled state.

Not a toast. Toaster is mounted at App.tsx:99 but there are zero toast() calls in the app, so this would set a precedent rather than follow one. The feedback is also positional: these buttons sit in table rows and detail panels where several copy targets are visible at once, so "Copied" needs to be anchored to the button that was clicked. A corner toast loses which-one.

Per-button state falls out for free. ServersTable currently keys its state by value (copiedId === server.id) because one component owns many buttons. If each CopyButton holds its own hook instance, that bookkeeping disappears.

// src/hooks/useCopyToClipboard.ts
type CopyStatus = "idle" | "copied" | "error";

export function useCopyToClipboard(resetDelayMs = 1500) {
  const [status, setStatus] = useState<CopyStatus>("idle");
  const timeoutRef = useRef<number | null>(null);

  useEffect(
    () => () => {
      if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
    },
    [],
  );

  const copy = useCallback(
    async (value: string) => {
      const ok = await copyToClipboard(value);
      setStatus(ok ? "copied" : "error");
      if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
      timeoutRef.current = window.setTimeout(() => {
        setStatus("idle");
        timeoutRef.current = null;
      }, resetDelayMs);
      return ok;
    },
    [resetDelayMs],
  );

  return { status, copy };
}

Design notes

Tooltip: hybrid open, not controlled and not uncontrolled

Radix tooltips open on hover/focus. CodeBlock today passes open={copied}, which fully controls the tooltip and gives up the ordinary "Copy resource ID" hover hint. Bare icon buttons in table rows need that hint more than they need the confirmation.

Leaving the tooltip uncontrolled and swapping only its content breaks touch: with no hover event the tooltip never opens on tap, so touch users see nothing. That is the failure VisibilityInfoPopover was written to avoid (its docstring: popover, not tooltip, "so the explanation is reachable on touch devices").

Keep both:

const [hoverOpen, setHoverOpen] = useState(false);
const copied = status === "copied";

<Tooltip open={copied || hoverOpen} onOpenChange={setHoverOpen} delayDuration={400}>
  <TooltipTrigger asChild>
    <Button
      type="button"
      variant="ghost"
      size={size}
      aria-label={label}
      onClick={(e) => {
        e.stopPropagation();
        void copy(value);
      }}
    >
      {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
    </Button>
  </TooltipTrigger>
  <TooltipContent side="top">{copied ? copiedLabel : label}</TooltipContent>
</Tooltip>

Hover keeps working and the confirmation is force-opened, so it shows on tap too. A tooltip is fine here where it was not fine for the info icons, because the confirmation is opened programmatically rather than by hover.

QA on a device: Radix may leave the trigger focused after a tap, so once copied resets the tooltip can linger showing the plain "Copy X" label.

copyToClipboard cannot report success

// src/lib/clipboard.ts
export function copyToClipboard(value: string) {
  void navigator.clipboard?.writeText(value);
}

Fire-and-forget, swallows rejection. To assert "Copied!" only on success this becomes Promise<boolean>. Existing callers ignore the return value, so they are unaffected.

The failure branch is not just theoretical: navigator.clipboard is undefined outside a secure context. That is narrow here, though. server/src/config.ts:52 defaults cookieSecure to true, and a Secure cookie is not sent over plain HTTP, so a default deployment reached over http:// cannot log in at all. localhost and 127.0.0.1 are secure contexts, so npm run dev is fine. The only way to reach this is setting COOKIE_SECURE=false and serving on a LAN IP, which DOCKER.md:68 tells you not to do.

So: no document.execCommand fallback. It needs a live selection, an injected textarea, focus and selection restore, and a contentEditable + Range special case for iOS Safari, all on a deprecated API, to serve a configuration the docs warn against. The error state is enough. The user sees "Copy failed" and can select the text manually, which beats today's silent no-op.

One announcement, not two

ServersTable and ToolForm added sr-only text because tooltips are largely unannounced. Folding that into CopyButton gets it everywhere, but watch for double announcement: Radix renders TooltipContent as the trigger's accessible description in a hidden portal, so a content swap plus a separate live region can announce twice.

Use a single sr-only role="status" region owned by CopyButton, and keep aria-label fixed at "Copy {label}". Mutating the accessible name mid-interaction re-announces on some screen reader and browser pairs. Then delete the hand-rolled spans at ServersTable.tsx:259 and ToolForm.tsx:434,468.

Root TooltipProvider at 0ms, CopyButton overrides locally

There is no provider at the app root. code-block.tsx:139, card-tag.tsx:47, and Gateways.tsx:267 each mount their own. Mount one in App.tsx and remove the local ones.

Keep the root at delayDuration={0}, matching our wrapper's default, so CardTag and the Gateways info icon behave exactly as they do now. Tooltip root accepts its own delayDuration that overrides the provider, so CopyButton sets ~400ms for itself. Those table copy buttons have no tooltip today, only an aria-label, so this adds ~14 new hover targets, some two-per-row (ResourcesTable has a URI copy and an ID copy plus row actions). 0ms on net-new targets is what would feel twitchy.

One side effect to expect: skipDelayDuration (300ms default) exists only on the provider. Today each island has its own, so the window never crosses between them. A single root provider makes it app-wide, so hovering a CardTag then a copy icon within 300ms opens the copy tooltip instantly regardless of its 400ms. That is what the setting is for, but it will look like a bug in review if nobody wrote it down.

Leave disableHoverableContent at its default. The click-triggered "Copied!" does not need hoverable content, but the hover-triggered "Copy resource ID" label does, per WCAG 1.4.13.

stopPropagation

All table call sites already stop propagation so the copy click does not select the row (ToolsTable.tsx:102, ResourcesTable.tsx:89,110, PromptDefinitionTable.tsx:103). CopyButton should do it internally so it cannot be forgotten at a new call site.

Scope

  • src/lib/clipboard.ts returns Promise<boolean>
  • src/hooks/useCopyToClipboard.ts (new)
  • src/components/ui/copy-button.tsx (new) + test
  • Mount TooltipProvider in App.tsx; drop local providers in code-block.tsx, card-tag.tsx, Gateways.tsx
  • ui/copy-value.tsx and ui/code-block.tsx delegate to CopyButton
  • Migrate the 12 inline sites in the table above
  • ServersTable and ToolForm drop hand-rolled state and sr-only spans
  • i18n: add common.copied and common.copyFailed to en-US, es-ES, pt-BR. src/i18n/locales.test.ts enforces key parity, so a partial add fails CI. Existing mcpServer.table.copied, tools.form.copied, and prompts.details.code.copySuccess all say "Copied!" and can be retired.

Tests

  • Hook: sets copied on success, error on rejection, resets after the delay, clears the timer on unmount, restarts the timer on a rapid second click.
  • CopyButton: renders Check and the localized copied label after click, keeps the hover label when idle, announces once via role="status", calls stopPropagation.
  • Integration: a copy click does not trigger row selection. ResourcesTable is a good candidate, its rows are clickable at line 60.

Non-goals

  • document.execCommand fallback for non-secure contexts.
  • Converting any of this to toasts.
  • Adding copy buttons where none exist today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions