From 5608f2b3fa67031234ed0e7a8a418e32384963ac Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Sun, 14 Jun 2026 22:08:13 +0200 Subject: [PATCH] fix(settings): give the MCP copy button success feedback (#2314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): give the MCP copy button success feedback The MCP server URL Copy button copied to the clipboard but showed no feedback. It is a DS::Button (single icon), but clipboard_controller's showSuccess() unconditionally toggled iconDefault/iconSuccess targets — which that markup does not have — so it threw right after the copy and the user saw nothing. Guard the icon-swap path (still used by invite codes, MFA and profiles) and add a fallback that briefly flips the button's own label to Copied! via a new copiedText value. Wire it up on the MCP page. * fix(settings): capture copy button before async clipboard resolve event.currentTarget is null by the time the writeText().then() callback runs (it's only valid during event dispatch), so showSuccess received null and the label never flipped. Capture the button synchronously in copy() and pass it through. Verified in-browser: Copy -> Copied! -> Copy. * refactor(clipboard): unify feedback reset delay, harden label lookup Extract a shared RESET_DELAY_MS so the icon-swap and label-flash paths last the same duration when both copy buttons render on one page. Scope the label lookup to span.truncate (the DS::Button text node) so it ignores any future icon span. Per review feedback. --------- Co-authored-by: Guillem Arias --- .../controllers/clipboard_controller.js | 75 ++++++++++++++----- app/views/settings/mcp/show.html.erb | 2 +- config/locales/views/settings/en.yml | 1 + 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js index 7e0b1a493..d895ac7b8 100644 --- a/app/javascript/controllers/clipboard_controller.js +++ b/app/javascript/controllers/clipboard_controller.js @@ -1,28 +1,69 @@ import { Controller } from "@hotwired/stimulus"; +// Single source of truth so the icon-swap and label-flash feedback last the +// same time when both copy buttons appear on one page. +const RESET_DELAY_MS = 2000; + export default class extends Controller { static targets = ["source", "iconDefault", "iconSuccess"]; + static values = { copiedText: String }; copy(event) { event.preventDefault(); - if (this.sourceTarget?.textContent) { - navigator.clipboard - .writeText(this.sourceTarget.textContent) - .then(() => { - this.showSuccess(); - }) - .catch((error) => { - console.error("Failed to copy text: ", error); - }); - } + // Capture the button now: `event.currentTarget` is reset to null once the + // event finishes dispatching, so it can't be read inside the async `.then`. + const button = event.currentTarget; + const text = this.sourceTarget?.textContent; + if (!text) return; + + navigator.clipboard + .writeText(text) + .then(() => { + this.showSuccess(button); + }) + .catch((error) => { + console.error("Failed to copy text: ", error); + }); } - showSuccess() { - this.iconDefaultTarget.classList.add("hidden"); - this.iconSuccessTarget.classList.remove("hidden"); - setTimeout(() => { - this.iconDefaultTarget.classList.remove("hidden"); - this.iconSuccessTarget.classList.add("hidden"); - }, 3000); + showSuccess(button) { + // Markup that ships explicit default/success icons (invite codes, MFA, + // profiles) toggles between them. + if (this.hasIconDefaultTarget && this.hasIconSuccessTarget) { + this.iconDefaultTarget.classList.add("hidden"); + this.iconSuccessTarget.classList.remove("hidden"); + setTimeout(() => { + this.iconDefaultTarget.classList.remove("hidden"); + this.iconSuccessTarget.classList.add("hidden"); + }, RESET_DELAY_MS); + return; + } + + // A single-icon button (e.g. DS::Button) has no icons to swap, so confirm + // the copy by briefly flipping the button's own label. + this.flashLabel(button); + } + + flashLabel(button) { + // DS::Button renders its icon as an and its text in `span.truncate`, + // so scope to that class rather than the first in case an icon ever + // ships wrapped in a span. + const label = button?.querySelector("span.truncate") ?? button?.querySelector("span"); + if (!label || !this.hasCopiedTextValue) return; + + clearTimeout(this.labelResetTimer); + if (this.originalLabel == null) { + this.originalLabel = label.textContent; + } + + label.textContent = this.copiedTextValue; + this.labelResetTimer = setTimeout(() => { + label.textContent = this.originalLabel; + this.originalLabel = null; + }, RESET_DELAY_MS); + } + + disconnect() { + clearTimeout(this.labelResetTimer); } } diff --git a/app/views/settings/mcp/show.html.erb b/app/views/settings/mcp/show.html.erb index 7e4160d21..b39e3c052 100644 --- a/app/views/settings/mcp/show.html.erb +++ b/app/views/settings/mcp/show.html.erb @@ -8,7 +8,7 @@

<%= t(".connect_subtitle") %>

-
+
">
<%= @mcp_url %> <%= render DS::Button.new( diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 9a5802d7c..2d9a98651 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -236,6 +236,7 @@ en: connect_title: Connect an AI assistant connect_subtitle: Paste this URL into Claude.ai (or any MCP-compatible client) to connect it to your Sure account. copy_url: Copy + copied: Copied! how_to_connect_title: How to connect Claude step_1: "Open Claude.ai and go to Settings → Integrations." step_2: Click "Add integration" and paste the MCP server URL above.