From 5ab25b3ff6affb9326e8af6f23142b1d7fcfcddb Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 11 Jun 2026 16:12:15 +0800 Subject: [PATCH] fix(site): add a custom copy/paste menu to the web terminal (#26015) ## Summary Fixes [CODAGT-415](https://linear.app/codercom/issue/CODAGT-415). Right-clicking selected text in the web terminal on Windows (and Linux) showed the browser's image actions ("Copy image", "Save image as") instead of copy/paste. The terminal uses xterm.js with the canvas/WebGL renderer, so the underlying element is a ``, which Chromium and Firefox treat as an image. xterm.js tries to retarget the menu by moving a hidden textarea under the cursor, but on Windows and Linux the browser's own non-native context menu locks onto the canvas before that workaround lands. ## Change Wrap the terminal in the shared Radix `ContextMenu` so right-click shows a custom **Copy** / **Paste** menu instead of the browser default: - **Copy** reuses the existing copy-on-select clipboard path (`getSelection()` + `copyToClipboard`). It is disabled when there is no selection. - **Paste** reads the clipboard and uses xterm's `paste()`, which respects bracketed-paste mode. - The menu is gated to non-macOS (`disabled={isMac()}` on the trigger). macOS renders native context menus that already expose working copy/paste across Chrome, Firefox, and Safari, so its default is left untouched. ## Platform scope | Platform | Behavior | | --- | --- | | Windows (Chromium / Firefox) | Custom Copy/Paste menu (fixes the bug) | | Linux (Chromium / Firefox) | Custom Copy/Paste menu | | macOS (Chrome / Firefox / Safari) | Native menu preserved (already works) | ## Testing - `TerminalPage.test.tsx`: on non-macOS, right-click suppresses the native menu and shows the Copy/Paste menu; on macOS the native menu is preserved. - `TerminalPage.stories.tsx`: new `RightClickMenu` story opens the menu via a `play` function for real-browser and visual coverage. - `tsc`, `biome`, and `make pre-commit` (gen/fmt/lint/build) pass locally.
Decision log - The issue was originally reported as Windows-only. Hands-on testing confirmed macOS is not affected: Chrome, Firefox, and Safari on macOS all show a working copy/paste menu. The difference is the menu implementation: macOS uses native OS context menus (which pick up xterm's repositioned textarea), while Chromium/Firefox on Windows and Linux draw their own menu that targets the `` directly. - Root cause is the canvas/WebGL renderer plus the unreliability of xterm's textarea-repositioning workaround on non-native menus, not the operating system itself. - A custom menu (rather than just `preventDefault`) was chosen so users keep an explicit copy/paste affordance on the affected platforms. A bare `preventDefault` removes the menu entirely. - Scope is gated to non-macOS to avoid regressing the working native menu on macOS. Rejected alternatives: suppressing/replacing on all platforms (regresses macOS), and Windows-only (misses Linux, which shares the same non-native menu).
--- Generated by Coder Agents on behalf of @jaaydenh. --- site/src/hooks/useClipboard.test.tsx | 40 ++++++++++ site/src/hooks/useClipboard.ts | 26 +++++- .../modules/terminal/WorkspaceTerminal.tsx | 80 ++++++++++++++++--- .../TerminalPage/TerminalPage.stories.tsx | 47 +++++++++++ 4 files changed, 180 insertions(+), 13 deletions(-) diff --git a/site/src/hooks/useClipboard.test.tsx b/site/src/hooks/useClipboard.test.tsx index dde538614c..a2a4969c91 100644 --- a/site/src/hooks/useClipboard.test.tsx +++ b/site/src/hooks/useClipboard.test.tsx @@ -157,6 +157,8 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { clipboard: mockClipboard, })); + vi.stubGlobal("isSecureContext", isSecure); + vi.spyOn(console, "error").mockImplementation((errorValue, ...rest) => { const canIgnore = errorValue instanceof Error && @@ -172,6 +174,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { vi.runAllTimers(); vi.useRealTimers(); vi.resetAllMocks(); + vi.unstubAllGlobals(); global.document.execCommand = originalExecCommand; // Still have to reset the mock clipboard state because the same mock values @@ -299,6 +302,43 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { expect(result.current.copyToClipboard).toBe(initialCopy); }); + it("Reads back text that was copied through the hook", async () => { + const textToCopy = "wolves"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + }); + + it("Surfaces read failures in secure contexts and falls back to the cached value otherwise", async () => { + const textToCopy = "otters"; + const { result } = renderUseClipboard(); + await assertClipboardUpdateLifecycle(result, textToCopy); + + if (isSecure) { + // A failed or denied read must surface instead of silently pasting a + // stale cached selection. + setSimulateFailure(true); + await expect( + act(async () => { + await result.current.readFromClipboard(); + }), + ).rejects.toThrow(); + } else { + // Insecure contexts cannot read the system clipboard, so paste falls + // back to the last value copied within Coder. + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(textToCopy); + } + }); + + it("Returns an empty string when nothing has been copied yet", async () => { + const { result } = renderUseClipboard(); + const readText = await act(() => result.current.readFromClipboard()); + expect(readText).toEqual(""); + }); + it("Always uses the most up-to-date onError prop", async () => { const initialOnError = vi.fn(); const { result, rerender } = renderUseClipboard({ diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts index 11c2518ba8..fdbb2d9235 100644 --- a/site/src/hooks/useClipboard.ts +++ b/site/src/hooks/useClipboard.ts @@ -13,6 +13,13 @@ export type UseClipboardInput = Readonly<{ export type UseClipboardResult = Readonly<{ copyToClipboard: (textToCopy: string) => Promise; + + /** + * Reads text from the clipboard. When the asynchronous Clipboard API is + * available (secure contexts), it returns the live clipboard contents. + */ + readFromClipboard: () => Promise; + error: Error | undefined; /** @@ -44,6 +51,7 @@ export const useClipboard = ( const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); const [error, setError] = useState(); const timeoutIdRef = useRef(undefined); + const lastCopiedTextRef = useRef(""); useEffect(() => { return () => window.clearTimeout(timeoutIdRef.current); @@ -52,6 +60,7 @@ export const useClipboard = ( const copyToClipboard = useCallback( async (textToCopy: string) => { const markSuccess = () => { + lastCopiedTextRef.current = textToCopy; setShowCopiedSuccess(true); if (clearErrorOnSuccess) { setError(undefined); @@ -84,7 +93,22 @@ export const useClipboard = ( [onError, clearErrorOnSuccess], ); - return { showCopiedSuccess, error, copyToClipboard }; + const readFromClipboard = useCallback(async (): Promise => { + // Insecure (HTTP) contexts and older browsers cannot read the system + // clipboard, so fall back to the last value copied within Coder. In a + // secure context, surface read failures (such as a denied permission) + // instead of silently pasting a stale cached selection. + if ( + window.isSecureContext && + typeof navigator.clipboard?.readText === "function" + ) { + return await navigator.clipboard.readText(); + } + + return lastCopiedTextRef.current; + }, []); + + return { showCopiedSuccess, error, copyToClipboard, readFromClipboard }; }; /** diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 7e4f824c19..058fdd7ff7 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -15,14 +15,22 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import { ExponentialBackoff, type Websocket, WebsocketBuilder, WebsocketEvent, } from "websocket-ts"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "#/components/ContextMenu/ContextMenu"; import { useClipboard } from "#/hooks/useClipboard"; import { cn } from "#/utils/cn"; +import { isMac } from "#/utils/platform"; import { terminalWebsocketUrl } from "#/utils/terminal"; import type { ConnectionStatus } from "./types"; @@ -98,7 +106,36 @@ export const WorkspaceTerminal = ({ onContentReady?.(); }); const [terminal, setTerminal] = useState(); - const { copyToClipboard } = useClipboard(); + const { copyToClipboard, readFromClipboard } = useClipboard(); + + const [hasSelection, setHasSelection] = useState(false); + const handleContextMenuOpenChange = (open: boolean) => { + if (open) { + setHasSelection(Boolean(terminal?.hasSelection())); + } + }; + const copyTerminalSelection = () => { + const selection = terminal?.getSelection(); + if (selection) { + void copyToClipboard(selection); + } + }; + const pasteIntoTerminal = async () => { + if (!terminal) { + return; + } + try { + const text = await readFromClipboard(); + if (text) { + terminal.paste(text); + } + } catch (error) { + toast.error("Failed to paste from clipboard"); + console.error(error); + } finally { + terminal.focus(); + } + }; const reportTerminalError = useEffectEvent((error: Error) => { console.error(error); @@ -203,7 +240,6 @@ export const WorkspaceTerminal = ({ }), ); - const isMac = navigator.platform.match("Mac"); const copySelection = () => { const selection = nextTerminal.getSelection(); if (selection) { @@ -229,7 +265,7 @@ export const WorkspaceTerminal = ({ // By default this usually launches the browser dev tools, but users // expect this keybinding to copy when in the context of the web terminal. if ( - (isMac ? event.metaKey : event.ctrlKey) && + (isMac() ? event.metaKey : event.ctrlKey) && event.shiftKey && event.key === "C" ) { @@ -550,15 +586,35 @@ export const WorkspaceTerminal = ({ background-color: hsl(var(--surface-quaternary)); } `} -
+ + +
+ + { + event.preventDefault(); + terminal?.focus(); + }} + > + + Copy + + void pasteIntoTerminal()}> + Paste + + + ); }; diff --git a/site/src/pages/TerminalPage/TerminalPage.stories.tsx b/site/src/pages/TerminalPage/TerminalPage.stories.tsx index 947ad72872..e15d192f8a 100644 --- a/site/src/pages/TerminalPage/TerminalPage.stories.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor, within } from "storybook/test"; import { reactRouterOutlet, reactRouterParameters, @@ -23,6 +24,7 @@ import { MockWorkspaceAgent, } from "#/testHelpers/entities"; import { withWebSocket } from "#/testHelpers/storybook"; +import { isMac } from "#/utils/platform"; import TerminalPage from "./TerminalPage"; const createWorkspaceWithAgent = (lifecycle: WorkspaceAgentLifecycle) => { @@ -170,6 +172,51 @@ export const Ready: Story = { }, }; +export const RightClickMenu: Story = { + decorators: [withWebSocket], + parameters: { + ...meta.parameters, + webSocket: [ + { + event: "message", + data: "$ echo hello", + }, + ], + queries: [...meta.parameters.queries, createWorkspaceWithAgent("ready")], + }, + play: async ({ canvasElement }) => { + if (isMac()) { + return; + } + + const terminal = await waitFor(() => { + const element = canvasElement.querySelector(".xterm"); + if (!element) { + throw new Error("terminal has not rendered yet"); + } + return element; + }); + + const rect = terminal.getBoundingClientRect(); + terminal.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }), + ); + + const body = within(document.body); + await expect( + await body.findByRole("menuitem", { name: "Copy" }), + ).toBeInTheDocument(); + await expect( + body.getByRole("menuitem", { name: "Paste" }), + ).toBeInTheDocument(); + }, +}; + export const StartError: Story = { decorators: [withWebSocket], parameters: {