From f36f15f9671187bbf8a8054a17b4c4a4c85869a3 Mon Sep 17 00:00:00 2001 From: Matthew Jaffee Date: Thu, 12 Feb 2026 21:43:52 -0600 Subject: [PATCH] fix: copy to clipboard in non-secure contexts (HTTP on non-localhost) navigator.clipboard is undefined in non-secure contexts (HTTP served on a non-localhost hostname like zebra.exe.xyz). Calling writeText on undefined throws a synchronous TypeError that the .catch() didn't handle. Extract a copyToClipboard utility that uses navigator.clipboard when available and falls back to textarea+execCommand otherwise. Use it in all clipboard write sites: Message copy action, commit hash copy, and terminal panel copy buttons. Co-authored-by: Shelley --- ui/src/components/Message.tsx | 5 +++-- ui/src/components/TerminalPanel.tsx | 5 +++-- ui/src/utils/clipboard.ts | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 ui/src/utils/clipboard.ts diff --git a/ui/src/components/Message.tsx b/ui/src/components/Message.tsx index 5f9d64c..0988860 100644 --- a/ui/src/components/Message.tsx +++ b/ui/src/components/Message.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; +import { copyToClipboard } from "../utils/clipboard"; import { linkifyText } from "../utils/linkify"; import { Message as MessageType, @@ -124,7 +125,7 @@ function GitInfoMessage({ const handleCopyHash = (e: React.MouseEvent) => { e.preventDefault(); if (commitHash) { - navigator.clipboard.writeText(commitHash).then(() => { + copyToClipboard(commitHash).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); @@ -416,7 +417,7 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp const handleCopy = () => { const text = getMessageText(); if (text) { - navigator.clipboard.writeText(text).catch((err) => { + copyToClipboard(text).catch((err) => { console.error("Failed to copy text:", err); }); } diff --git a/ui/src/components/TerminalPanel.tsx b/ui/src/components/TerminalPanel.tsx index 295842b..e56593f 100644 --- a/ui/src/components/TerminalPanel.tsx +++ b/ui/src/components/TerminalPanel.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { copyToClipboard } from "../utils/clipboard"; import { isDarkModeActive } from "../services/theme"; import "@xterm/xterm/css/xterm.css"; @@ -441,12 +442,12 @@ export default function TerminalPanel({ ); const copyScreen = useCallback(() => { - navigator.clipboard.writeText(getBufferText("screen")); + copyToClipboard(getBufferText("screen")); showFeedback("copyScreen"); }, [getBufferText, showFeedback]); const copyAll = useCallback(() => { - navigator.clipboard.writeText(getBufferText("all")); + copyToClipboard(getBufferText("all")); showFeedback("copyAll"); }, [getBufferText, showFeedback]); diff --git a/ui/src/utils/clipboard.ts b/ui/src/utils/clipboard.ts new file mode 100644 index 0000000..21c96d2 --- /dev/null +++ b/ui/src/utils/clipboard.ts @@ -0,0 +1,21 @@ +// Copy text to clipboard. +// Uses the modern Clipboard API when available (secure contexts), +// otherwise falls back to execCommand for non-secure contexts (e.g. HTTP on non-localhost). +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard) { + return navigator.clipboard.writeText(text); + } + + // Non-secure context: use textarea + execCommand + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +}