From 30f8b63f55ff07bb2a3a15c8022f0f48fe75031f Mon Sep 17 00:00:00 2001 From: Michel-Liao Date: Sun, 23 Aug 2026 12:00:40 -0700 Subject: [PATCH 1/3] fix(web): copy text over plain HTTP --- apps/web/src/hooks/useCopyToClipboard.test.ts | 38 +++++++++++++++++ apps/web/src/hooks/useCopyToClipboard.ts | 42 ++++++++++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index ccac333cd488..a855eb7f28d6 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => { it("reports unavailable clipboard support with structural context", async () => { vi.stubGlobal("window", {}); vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", undefined); const error = await writeTextToClipboard("plan contents", "plan").then( () => undefined, @@ -27,6 +28,43 @@ describe("writeTextToClipboard", () => { expect((error as Error).message).not.toContain("plan contents"); }); + it("falls back to a selected textarea when the Clipboard API is unavailable", async () => { + const focus = vi.fn(); + const appendChild = vi.fn(); + const execCommand = vi.fn(() => true); + const remove = vi.fn(); + const select = vi.fn(); + const setAttribute = vi.fn(); + const setSelectionRange = vi.fn(); + const textarea = { + remove, + select, + setAttribute, + setSelectionRange, + style: {}, + value: "", + }; + + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", { + activeElement: { focus }, + body: { appendChild }, + createElement: vi.fn(() => textarea), + execCommand, + }); + + await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true); + + expect(textarea.value).toBe("remote command"); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(select).toHaveBeenCalledOnce(); + expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(remove).toHaveBeenCalledOnce(); + expect(focus).toHaveBeenCalledOnce(); + }); + it("preserves the exact clipboard failure without exposing copied contents", async () => { const cause = new Error("browser clipboard failure"); const writeText = vi.fn().mockRejectedValue(cause); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index ef66410f7db4..7d7e2c1eb363 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -47,12 +47,37 @@ export class ClipboardReadError extends Schema.TaggedErrorClass Date: Sun, 23 Aug 2026 12:19:26 -0700 Subject: [PATCH 2/3] fix(web): focus fallback textarea before copy --- apps/web/src/hooks/useCopyToClipboard.test.ts | 7 +++++-- apps/web/src/hooks/useCopyToClipboard.ts | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index a855eb7f28d6..05601ec78b8a 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -30,6 +30,7 @@ describe("writeTextToClipboard", () => { it("falls back to a selected textarea when the Clipboard API is unavailable", async () => { const focus = vi.fn(); + const restoreFocus = vi.fn(); const appendChild = vi.fn(); const execCommand = vi.fn(() => true); const remove = vi.fn(); @@ -37,6 +38,7 @@ describe("writeTextToClipboard", () => { const setAttribute = vi.fn(); const setSelectionRange = vi.fn(); const textarea = { + focus, remove, select, setAttribute, @@ -48,7 +50,7 @@ describe("writeTextToClipboard", () => { vi.stubGlobal("window", {}); vi.stubGlobal("navigator", {}); vi.stubGlobal("document", { - activeElement: { focus }, + activeElement: { focus: restoreFocus }, body: { appendChild }, createElement: vi.fn(() => textarea), execCommand, @@ -58,11 +60,12 @@ describe("writeTextToClipboard", () => { expect(textarea.value).toBe("remote command"); expect(appendChild).toHaveBeenCalledWith(textarea); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(select).toHaveBeenCalledOnce(); expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length); expect(execCommand).toHaveBeenCalledWith("copy"); expect(remove).toHaveBeenCalledOnce(); - expect(focus).toHaveBeenCalledOnce(); + expect(restoreFocus).toHaveBeenCalledOnce(); }); it("preserves the exact clipboard failure without exposing copied contents", async () => { diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 7d7e2c1eb363..83a2604e4057 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -56,12 +56,14 @@ function writeTextWithExecCommand(value: string): boolean { textarea.setAttribute("readonly", ""); textarea.setAttribute("aria-hidden", "true"); textarea.style.position = "fixed"; - textarea.style.top = "-9999px"; + textarea.style.top = "0"; + textarea.style.left = "0"; textarea.style.opacity = "0"; const previouslyFocused = document.activeElement; document.body.appendChild(textarea); try { + textarea.focus({ preventScroll: true }); textarea.select(); textarea.setSelectionRange(0, value.length); return document.execCommand("copy"); From 1cc367e09a1db0be1e6361020d509f1e3f20ce93 Mon Sep 17 00:00:00 2001 From: Michel-Liao Date: Sun, 23 Aug 2026 12:27:39 -0700 Subject: [PATCH 3/3] fix(web): prevent clipboard fallback zoom --- apps/web/src/hooks/useCopyToClipboard.test.ts | 1 + apps/web/src/hooks/useCopyToClipboard.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index 05601ec78b8a..ec6b5566bea5 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -59,6 +59,7 @@ describe("writeTextToClipboard", () => { await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true); expect(textarea.value).toBe("remote command"); + expect(textarea.style).toMatchObject({ fontSize: "16px" }); expect(appendChild).toHaveBeenCalledWith(textarea); expect(focus).toHaveBeenCalledWith({ preventScroll: true }); expect(select).toHaveBeenCalledOnce(); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 83a2604e4057..8723aaeb941a 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -59,6 +59,7 @@ function writeTextWithExecCommand(value: string): boolean { textarea.style.top = "0"; textarea.style.left = "0"; textarea.style.opacity = "0"; + textarea.style.fontSize = "16px"; const previouslyFocused = document.activeElement; document.body.appendChild(textarea);