From b9612ec104d19f3a9322d4335fd275afc89d2136 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Thu, 20 Aug 2026 20:27:06 +0100 Subject: [PATCH 1/3] fix: consistent Copied feedback on every copy button Signed-off-by: Marek Dano --- src/App.tsx | 7 +- .../gateways/VirtualServerDetailsPanel.tsx | 29 ++---- .../prompts/PromptDefinitionTable.tsx | 21 ++--- .../prompts/PromptPreviewResult.tsx | 1 - src/components/prompts/PromptSnippetTabs.tsx | 3 - .../resources/ResourcesTable.test.tsx | 2 +- src/components/resources/ResourcesTable.tsx | 38 +++----- .../servers/MCPServerDetailsPanel.tsx | 29 ++---- src/components/servers/ServersTable.test.tsx | 19 ++-- src/components/servers/ServersTable.tsx | 70 +++----------- .../servers/TestConnectionPanel.test.tsx | 3 +- .../servers/TestConnectionPanel.tsx | 17 ++-- src/components/tokens/TokenCreatedDialog.tsx | 32 ++++--- src/components/tools/ToolForm.test.tsx | 21 +++-- src/components/tools/ToolForm.tsx | 50 +++------- .../tools/ToolSchemaDialog.test.tsx | 5 +- src/components/tools/ToolSchemaDialog.tsx | 38 +++++--- src/components/tools/ToolsTable.test.tsx | 2 +- src/components/tools/ToolsTable.tsx | 38 +++----- src/components/ui/card-tag.test.tsx | 12 ++- src/components/ui/card-tag.tsx | 16 ++-- src/components/ui/code-block.test.tsx | 15 +-- src/components/ui/code-block.tsx | 73 ++------------- src/components/ui/copy-button.test.tsx | 50 ++++++++++ src/components/ui/copy-button.tsx | 80 ++++++++++++++++ src/components/ui/copy-value.tsx | 17 +--- src/hooks/useCopyToClipboard.test.ts | 93 +++++++++++++++++++ src/hooks/useCopyToClipboard.ts | 38 ++++++++ src/i18n/locales/en-US/common.json | 2 + src/i18n/locales/en-US/mcpServer.json | 1 - src/i18n/locales/en-US/prompts.json | 1 - src/i18n/locales/en-US/tools.json | 1 - src/i18n/locales/es-ES/common.json | 2 + src/i18n/locales/es-ES/mcpServer.json | 1 - src/i18n/locales/es-ES/prompts.json | 1 - src/i18n/locales/es-ES/tools.json | 1 - src/i18n/locales/pt-BR/common.json | 2 + src/i18n/locales/pt-BR/mcpServer.json | 1 - src/i18n/locales/pt-BR/prompts.json | 1 - src/i18n/locales/pt-BR/tools.json | 1 - src/lib/clipboard.test.ts | 21 ++++- src/lib/clipboard.ts | 11 ++- src/pages/Gateways.tsx | 26 +++--- src/pages/Resources.test.tsx | 5 +- src/pages/Servers.test.tsx | 5 +- src/pages/Tools.test.tsx | 5 +- src/test/test-utils.tsx | 7 +- 47 files changed, 510 insertions(+), 404 deletions(-) create mode 100644 src/components/ui/copy-button.test.tsx create mode 100644 src/components/ui/copy-button.tsx create mode 100644 src/hooks/useCopyToClipboard.test.ts create mode 100644 src/hooks/useCopyToClipboard.ts diff --git a/src/App.tsx b/src/App.tsx index 47a621a..ea18b14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { AuthProvider } from "./auth/AuthContext"; import { ThemeProvider } from "./hooks/useTheme"; import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { RouterProvider, Route, Redirect, AuthGuard, useRouter } from "./router"; import { AppShell } from "./components/layout/AppShell"; import { Login } from "./pages/Login"; @@ -97,8 +98,10 @@ export function App() { - - + + + + diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index a2e0338..ca51d8d 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -4,7 +4,6 @@ import { useIntl } from "react-intl"; import { Activity, Box, - Copy, EllipsisVertical, Loader2, MessageSquareCode, @@ -20,13 +19,13 @@ import { import { MCPIcon } from "@/components/icons/MCPIcon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { CopyButton } from "@/components/ui/copy-button"; import { InlineTagAdd } from "@/components/ui/inline-tag-add"; import { CopyValue } from "@/components/ui/copy-value"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import type { MCPServer, VirtualServer } from "@/types/server"; import type { ComponentFilter } from "@/components/gateways/types"; -import { copyToClipboard } from "@/lib/clipboard"; import { buildComponentItems, formatServerDateTime, @@ -586,32 +585,22 @@ export function VirtualServerDetailsPanel({ {title} {identifier} - + /> ) : ( <> {identifier} - + /> {identifier} - + /> ) : ( <> {identifier} - + /> @@ -598,7 +601,10 @@ export function VirtualServerDetailsPanel({ {identifier} diff --git a/src/components/prompts/PromptPreviewResult.tsx b/src/components/prompts/PromptPreviewResult.tsx index 65facdc..aa89e7f 100644 --- a/src/components/prompts/PromptPreviewResult.tsx +++ b/src/components/prompts/PromptPreviewResult.tsx @@ -60,7 +60,7 @@ export function PromptPreviewResult({ preview }: PromptPreviewResultProps) { )} diff --git a/src/components/servers/MCPServerDetailsPanel.tsx b/src/components/servers/MCPServerDetailsPanel.tsx index 57b7b62..b74a5d2 100644 --- a/src/components/servers/MCPServerDetailsPanel.tsx +++ b/src/components/servers/MCPServerDetailsPanel.tsx @@ -490,7 +490,10 @@ export function MCPServerDetailsPanel({ {identifier} @@ -501,7 +504,10 @@ export function MCPServerDetailsPanel({ {identifier} diff --git a/src/components/servers/TestConnectionPanel.tsx b/src/components/servers/TestConnectionPanel.tsx index 7703ea7..56cfa8a 100644 --- a/src/components/servers/TestConnectionPanel.tsx +++ b/src/components/servers/TestConnectionPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { CircleCheck, CircleAlert, Info, Loader2 } from "lucide-react"; +import { useIntl } from "react-intl"; import { Button } from "../ui/button"; import { CopyButton } from "../ui/copy-button"; import { Input } from "../ui/input"; @@ -123,6 +124,7 @@ function FieldLabel({ } export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { + const intl = useIntl(); const [status, setStatus] = useState("idle"); const [method, setMethod] = useState("Get"); const [url, setUrl] = useState(serverUrl); @@ -441,7 +443,7 @@ export function TestConnectionPanel({ serverUrl }: TestConnectionPanelProps) { {responseBodyText && ( )} diff --git a/src/components/tools/ToolSchemaDialog.test.tsx b/src/components/tools/ToolSchemaDialog.test.tsx index d6220a3..b46c188 100644 --- a/src/components/tools/ToolSchemaDialog.test.tsx +++ b/src/components/tools/ToolSchemaDialog.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders as render } from "@/test/test-utils"; import { ToolSchemaDialog } from "./ToolSchemaDialog"; @@ -261,6 +261,37 @@ describe("ToolSchemaDialog", () => { expect(screen.getByText(/array/)).toBeInTheDocument(); }); + it("moves initial focus to the footer Close button, not a schema copy button", async () => { + const tool = createMockTool(); + render(); + + await waitFor(() => { + const allClose = screen.getAllByRole("button", { name: /close/i }); + const footerClose = allClose.find((btn) => !btn.querySelector("svg"))!; + expect(footerClose).toHaveFocus(); + }); + }); + + it("closes on a single Escape press instead of dismissing a copy button tooltip first", async () => { + const user = userEvent.setup(); + const tool = createMockTool(); + render(); + + // Wait for the auto-focus redirect to land on Close before pressing Escape, + // otherwise focus may still be mid-transition from the default (a copy + // button), which is exactly the regression this guards against. + await waitFor(() => { + const allClose = screen.getAllByRole("button", { name: /close/i }); + const footerClose = allClose.find((btn) => !btn.querySelector("svg"))!; + expect(footerClose).toHaveFocus(); + }); + + await user.keyboard("{Escape}"); + + expect(mockOnOpenChange).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + it("handles schemas with long text values", () => { const tool = createMockTool({ inputSchema: { diff --git a/src/components/tools/ToolSchemaDialog.tsx b/src/components/tools/ToolSchemaDialog.tsx index 625de39..e788b12 100644 --- a/src/components/tools/ToolSchemaDialog.tsx +++ b/src/components/tools/ToolSchemaDialog.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; import { Code } from "lucide-react"; +import { useIntl } from "react-intl"; import { Dialog, DialogContent, @@ -26,6 +27,7 @@ function SchemaSection({ title: string; schema: Record | null | undefined; }) { + const intl = useIntl(); const schemaText = schema ? JSON.stringify(schema, null, 2) : "{}"; return ( @@ -39,7 +41,7 @@ function SchemaSection({ diff --git a/src/hooks/useCopyToClipboard.test.ts b/src/hooks/useCopyToClipboard.test.ts index b3f7b49..59481d9 100644 --- a/src/hooks/useCopyToClipboard.test.ts +++ b/src/hooks/useCopyToClipboard.test.ts @@ -11,6 +11,17 @@ function setClipboard(writeText: (value: string) => Promise) { }); } +/** A promise plus its resolver/rejecter, so a test can control exactly when it settles. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("useCopyToClipboard", () => { beforeEach(() => { vi.useFakeTimers(); @@ -90,4 +101,61 @@ describe("useCopyToClipboard", () => { unmount(); expect(clearTimeoutSpy).toHaveBeenCalled(); }); + + it("does not let a stale request clobber a newer one that already settled", async () => { + const first = deferred(); + const second = deferred(); + let call = 0; + setClipboard(() => { + call += 1; + return call === 1 ? first.promise : second.promise; + }); + const { result } = renderHook(() => useCopyToClipboard()); + + let firstCopy!: Promise; + let secondCopy!: Promise; + act(() => { + firstCopy = result.current.copy("first"); + }); + act(() => { + secondCopy = result.current.copy("second"); + }); + + // The newer (second) request resolves first... + second.resolve(); + await act(async () => { + await secondCopy; + }); + expect(result.current.status).toBe("copied"); + + // ...and the older (first) request rejects after it. Since it's stale it + // must be ignored rather than flipping status to "error". + first.reject(new Error("denied")); + await act(async () => { + await firstCopy; + }); + expect(result.current.status).toBe("copied"); + }); + + it("ignores a completion that arrives after unmount", async () => { + const { promise, resolve } = deferred(); + setClipboard(() => promise); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + const { result, unmount } = renderHook(() => useCopyToClipboard()); + + let copyPromise!: Promise; + act(() => { + copyPromise = result.current.copy("value"); + }); + + unmount(); + resolve(); + await act(async () => { + await copyPromise; + }); + + // The write finished after teardown, so no state update or reset timer + // should have been scheduled. + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts index bfbd090..a27eea7 100644 --- a/src/hooks/useCopyToClipboard.ts +++ b/src/hooks/useCopyToClipboard.ts @@ -12,17 +12,28 @@ export type CopyStatus = "idle" | "copied" | "error"; export function useCopyToClipboard(resetDelayMs = 1500) { const [status, setStatus] = useState("idle"); const timeoutRef = useRef(null); + const mountedRef = useRef(true); + const requestIdRef = useRef(0); - useEffect( - () => () => { + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; if (timeoutRef.current) window.clearTimeout(timeoutRef.current); - }, - [], - ); + }; + }, []); const copy = useCallback( async (value: string) => { + const requestId = ++requestIdRef.current; const ok = await copyToClipboard(value); + + // Ignore this result if a later copy() has since been fired (so an + // older request resolving out of order can't clobber newer feedback) + // or the component has unmounted (so we don't set state or schedule + // an uncleared timer after teardown). + if (!mountedRef.current || requestId !== requestIdRef.current) return ok; + setStatus(ok ? "copied" : "error"); if (timeoutRef.current) window.clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index bfe29e6..882f785 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -175,5 +175,6 @@ "mcpServer.caCert.hint": "Public certificate files only (.pem, .crt, .cer, .cert)", "mcpServer.caCert.invalidFiles": "{count, plural, one {Invalid file type: {files}.} other {Invalid file types: {files}.}} Only .pem, .crt, .cer, .cert files are allowed.", "mcpServer.caCert.filesSelected": "{count, plural, one {# file selected successfully.} other {# files selected successfully.}}", - "mcpServer.caCert.selected": "Selected: {files}" + "mcpServer.caCert.selected": "Selected: {files}", + "mcpServer.testConnection.copyResponseBody": "Copy response body" } diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index f928714..a9581c9 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -175,5 +175,6 @@ "mcpServer.caCert.hint": "Solo archivos de certificado público (.pem, .crt, .cer, .cert)", "mcpServer.caCert.invalidFiles": "{count, plural, one {Tipo de archivo no válido: {files}.} other {Tipos de archivo no válidos: {files}.}} Solo se permiten archivos .pem, .crt, .cer y .cert.", "mcpServer.caCert.filesSelected": "{count, plural, one {# archivo seleccionado correctamente.} other {# archivos seleccionados correctamente.}}", - "mcpServer.caCert.selected": "Seleccionados: {files}" + "mcpServer.caCert.selected": "Seleccionados: {files}", + "mcpServer.testConnection.copyResponseBody": "Copiar cuerpo de la respuesta" } diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index e426ec1..df0d342 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -175,5 +175,6 @@ "mcpServer.caCert.hint": "Somente arquivos de certificado público (.pem, .crt, .cer, .cert)", "mcpServer.caCert.invalidFiles": "{count, plural, one {Tipo de arquivo inválido: {files}.} other {Tipos de arquivo inválidos: {files}.}} Somente arquivos .pem, .crt, .cer e .cert são permitidos.", "mcpServer.caCert.filesSelected": "{count, plural, one {# arquivo selecionado com sucesso.} other {# arquivos selecionados com sucesso.}}", - "mcpServer.caCert.selected": "Selecionados: {files}" + "mcpServer.caCert.selected": "Selecionados: {files}", + "mcpServer.testConnection.copyResponseBody": "Copiar corpo da resposta" } From 3d70cace77f2ddab4f68a969502032b4393a37e1 Mon Sep 17 00:00:00 2001 From: Marek Dano Date: Fri, 21 Aug 2026 12:06:54 +0100 Subject: [PATCH 3/3] fix: generated types issues Signed-off-by: Marek Dano --- src/components/resources/ResourcePreviewResult.tsx | 2 -- src/components/resources/ResourceTryItTab.tsx | 2 -- src/components/tools/ToolPreviewResult.tsx | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/components/resources/ResourcePreviewResult.tsx b/src/components/resources/ResourcePreviewResult.tsx index 03fa805..a09a3d0 100644 --- a/src/components/resources/ResourcePreviewResult.tsx +++ b/src/components/resources/ResourcePreviewResult.tsx @@ -159,13 +159,11 @@ function ResourceContentPreview({ } if (kind === "code") { - const copiedLabel = intl.formatMessage({ id: "resources.details.code.copySuccess" }); return ( ); } diff --git a/src/components/resources/ResourceTryItTab.tsx b/src/components/resources/ResourceTryItTab.tsx index 173622d..3fe07a2 100644 --- a/src/components/resources/ResourceTryItTab.tsx +++ b/src/components/resources/ResourceTryItTab.tsx @@ -122,7 +122,6 @@ function ResourcePreviewPane({ resource }: { resource: NonNullable [preview], ); - const copiedLabel = intl.formatMessage({ id: "resources.details.code.copySuccess" }); const rendered = useMemo( () => RESOURCE_SNIPPETS.map((spec) => ({ ...spec, text: spec.build({ uri: resolvedUri }) })), [resolvedUri], @@ -154,7 +153,6 @@ function ResourcePreviewPane({ resource }: { resource: NonNullable { id: "resources.details.code.copyAriaLabel" }, { language: snippet.language }, )} - copiedLabel={copiedLabel} /> ))} diff --git a/src/components/tools/ToolPreviewResult.tsx b/src/components/tools/ToolPreviewResult.tsx index daa2309..bee89f6 100644 --- a/src/components/tools/ToolPreviewResult.tsx +++ b/src/components/tools/ToolPreviewResult.tsx @@ -88,7 +88,6 @@ export function ToolPreviewResult({ preview }: ToolPreviewResultProps) { code={JSON.stringify(response.resolved_arguments, null, 2)} language="json" copyLabel={intl.formatMessage({ id: "tools.details.preview.copyResolvedArguments" })} - copiedLabel={intl.formatMessage({ id: "tools.details.preview.copied" })} /> )} @@ -102,7 +101,6 @@ export function ToolPreviewResult({ preview }: ToolPreviewResultProps) { code={JSON.stringify(response, null, 2)} language="json" copyLabel={intl.formatMessage({ id: "tools.details.preview.copyRawResponse" })} - copiedLabel={intl.formatMessage({ id: "tools.details.preview.copied" })} /> )}