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 `<canvas>`, 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.

<details>
<summary>Decision log</summary>

- 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
  `<canvas>` 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).

</details>

---

Generated by Coder Agents on behalf of @jaaydenh.
This commit is contained in:
Jaayden Halko
2026-06-11 09:12:15 +01:00
committed by GitHub
parent 78a6ec293e
commit 5ab25b3ff6
4 changed files with 180 additions and 13 deletions
+40
View File
@@ -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({
+25 -1
View File
@@ -13,6 +13,13 @@ export type UseClipboardInput = Readonly<{
export type UseClipboardResult = Readonly<{
copyToClipboard: (textToCopy: string) => Promise<void>;
/**
* Reads text from the clipboard. When the asynchronous Clipboard API is
* available (secure contexts), it returns the live clipboard contents.
*/
readFromClipboard: () => Promise<string>;
error: Error | undefined;
/**
@@ -44,6 +51,7 @@ export const useClipboard = (
const [showCopiedSuccess, setShowCopiedSuccess] = useState(false);
const [error, setError] = useState<Error>();
const timeoutIdRef = useRef<number | undefined>(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<string> => {
// 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 };
};
/**
+68 -12
View File
@@ -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<Terminal>();
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));
}
`}</style>
<div
className={cn(
"workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary",
className,
)}
ref={terminalWrapperRef}
data-terminal-scope={scopeId}
data-testid={testId}
/>
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild disabled={isMac()}>
<div
className={cn(
"workspace-terminal h-full w-full flex-1 min-h-0 overflow-hidden bg-surface-tertiary",
className,
)}
ref={terminalWrapperRef}
data-terminal-scope={scopeId}
data-testid={testId}
/>
</ContextMenuTrigger>
<ContextMenuContent
onCloseAutoFocus={(event) => {
event.preventDefault();
terminal?.focus();
}}
>
<ContextMenuItem
disabled={!hasSelection}
onSelect={copyTerminalSelection}
>
Copy
</ContextMenuItem>
<ContextMenuItem onSelect={() => void pasteIntoTerminal()}>
Paste
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</>
);
};
@@ -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<HTMLElement>(".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: {