From 49aa9049328282a9117daea6e68179da5dd91e1f Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Thu, 16 Apr 2026 18:22:56 -0500 Subject: [PATCH 1/3] fix(web): improve code block copy button for mobile and add position setting Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/clientPersistence.test.ts | 1 + .../src/components/ChatMarkdown.logic.test.ts | 56 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 51 ++++++++++++----- .../components/settings/SettingsPanels.tsx | 45 +++++++++++++++ apps/web/src/index.css | 14 ++++- apps/web/src/localApi.test.ts | 5 ++ packages/contracts/src/settings.ts | 7 +++ 7 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.logic.test.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0d..963c246e098 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -55,6 +55,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", + copyButtonPosition: "top", }; const savedRegistryRecord: PersistedSavedEnvironmentRecord = { diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts new file mode 100644 index 00000000000..efa57ecb9ff --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.logic.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +/** + * Tests for code block copy-button logic used by MarkdownCodeBlock. + * + * The component provides two improvements over the original implementation: + * + * 1. A configurable copy button position (top or bottom) via client settings. + * Long code blocks on mobile make the top-right button hard to reach; + * the "bottom" option places it at the end of the block instead. + * + * 2. A clipboard fallback for non-secure contexts. The Clipboard API + * (`navigator.clipboard`) requires HTTPS, but remote T3 Code sessions + * served over HTTP (e.g. Tailscale) lack it. The fallback uses the + * legacy `document.execCommand("copy")` path. + */ + +describe("copy button position style", () => { + const resolvePositionStyle = (position: "top" | "bottom") => + position === "bottom" + ? { top: "auto", bottom: "0.5rem" } + : { top: "0.5rem" }; + + it("returns top positioning by default", () => { + expect(resolvePositionStyle("top")).toEqual({ top: "0.5rem" }); + }); + + it("returns bottom positioning with top explicitly set to auto", () => { + const style = resolvePositionStyle("bottom"); + expect(style).toEqual({ top: "auto", bottom: "0.5rem" }); + }); + + it("overrides the CSS top rule when set to bottom", () => { + const style = resolvePositionStyle("bottom"); + // The CSS stylesheet sets `top: 0.5rem` as a default. + // When the setting is "bottom", the inline style must explicitly + // set `top: "auto"` to cancel the stylesheet rule, otherwise + // both `top` and `bottom` apply and `top` wins per CSS spec. + expect(style.top).toBe("auto"); + expect(style.bottom).toBe("0.5rem"); + }); +}); + +describe("clipboard availability detection", () => { + it("identifies secure context by clipboard API presence", () => { + // The component checks: navigator.clipboard != null + // This mirrors the guard used in MarkdownCodeBlock.handleCopy + const hasClipboardApi = (nav: { clipboard?: unknown }) => + typeof nav !== "undefined" && nav.clipboard != null; + + expect(hasClipboardApi({ clipboard: { writeText: () => {} } })).toBe(true); + expect(hasClipboardApi({ clipboard: undefined })).toBe(false); + expect(hasClipboardApi({ clipboard: null })).toBe(false); + expect(hasClipboardApi({})).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ba1c944cc87..301e3a48ec9 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -25,6 +25,7 @@ import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { useSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; @@ -140,23 +141,39 @@ function getHighlighterPromise(language: string): Promise { function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); + const copyButtonPosition = useSettings((s) => s.copyButtonPosition); const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { + const onSuccess = () => { + if (copiedTimerRef.current != null) { + clearTimeout(copiedTimerRef.current); + } + setCopied(true); + copiedTimerRef.current = setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, 1200); + }; + + // Prefer the async Clipboard API (requires secure context) + if (typeof navigator !== "undefined" && navigator.clipboard != null) { + void navigator.clipboard.writeText(code).then(onSuccess).catch(() => undefined); return; } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch(() => undefined); + + // Fallback for non-secure contexts (e.g. HTTP over Tailscale) + try { + const textarea = document.createElement("textarea"); + textarea.value = code; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + onSuccess(); + } catch { + // silently fail + } }, [code]); useEffect( @@ -169,11 +186,17 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo [], ); + const positionStyle = + copyButtonPosition === "bottom" + ? { top: "auto", bottom: "0.5rem" } + : { top: "0.5rem" }; + return (