diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index c09bb3cebe6..8f65c630a54 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { className={cn("text-xs leading-[16px] underline", statusClassName)} onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(errorTraceId); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { { - copyTextWithHaptic(props.traceId); + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); }} className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" > diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index f5aa26be960..7b901ec4c66 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { className="underline" onLongPress={(event) => { event.stopPropagation(); - copyTextWithHaptic(statusTraceId); + copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); }} onPress={(event) => { event.stopPropagation(); diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index 9b8c96d25ea..373b0d3ef03 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { accessibilityHint="Copies the trace ID" accessibilityRole="button" className="underline decoration-dotted" - onPress={() => copyTextWithHaptic(props.connection.traceId!)} + onPress={() => + copyTextWithHaptic(props.connection.traceId!, { + target: "connection-trace-id", + }) + } > {props.connection.traceId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7424d856368..4f8ca9c747d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,3 @@ -import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { - void Clipboard.setStringAsync(value); - void Haptics.selectionAsync(); + copyTextWithHaptic(value, { + target: "thread-work-row", + feedback: "selection", + }); setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index d15a3a1a59b..236fb44cd6b 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ impactAsync: vi.fn(), + selectionAsync: vi.fn(), setStringAsync: vi.fn(), })); @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ Light: "light", }, impactAsync: mocks.impactAsync, + selectionAsync: mocks.selectionAsync, })); -import { copyTextWithHaptic } from "./copyTextWithHaptic"; +import { + CopyTextClipboardWriteError, + CopyTextHapticFeedbackError, + copyTextWithHaptic, +} from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { beforeEach(() => { vi.clearAllMocks(); mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); mocks.impactAsync.mockResolvedValue(undefined); + mocks.selectionAsync.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it("triggers haptic feedback without waiting for the clipboard promise", () => { @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); expect(mocks.impactAsync).toHaveBeenCalledWith("light"); }); + + it("preserves selection feedback for thread work rows", () => { + copyTextWithHaptic("work output", { + target: "thread-work-row", + feedback: "selection", + }); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); + expect(mocks.selectionAsync).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).not.toHaveBeenCalled(); + }); + + it("reports structured failures without including clipboard contents", async () => { + const clipboardCause = new Error("native clipboard failure"); + const hapticCause = new Error("native haptic failure"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); + mocks.impactAsync.mockRejectedValueOnce(hapticCause); + + copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); + + await vi.waitFor(() => { + expect(consoleError).toHaveBeenCalledTimes(2); + }); + + const failures = consoleError.mock.calls.map(([failure]) => failure); + const clipboardError = failures.find( + (failure) => failure instanceof CopyTextClipboardWriteError, + ); + expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); + expect(clipboardError).toMatchObject({ + target: "connection-trace-id", + cause: clipboardCause, + }); + expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); + + const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); + expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); + expect(hapticError).toMatchObject({ + target: "connection-trace-id", + feedback: "light-impact", + cause: hapticCause, + }); + expect((hapticError as Error).message).not.toContain("secret clipboard contents"); + }); }); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 80f725f5b00..1cc8c94eef7 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -1,7 +1,70 @@ +import * as Schema from "effect/Schema"; import * as Clipboard from "expo-clipboard"; import * as Haptics from "expo-haptics"; -export function copyTextWithHaptic(value: string): void { - void Clipboard.setStringAsync(value); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass()( + "CopyTextClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass()( + "CopyTextHapticFeedbackError", + { + target: Schema.String, + feedback: Schema.Literals(["light-impact", "selection"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; + } +} + +export function copyTextWithHaptic( + value: string, + options: { + readonly target?: string; + readonly feedback?: "light-impact" | "selection"; + } = {}, +): void { + const target = options.target ?? "text"; + const feedback = options.feedback ?? "light-impact"; + + void (async () => { + try { + await Clipboard.setStringAsync(value); + } catch (cause) { + console.error( + new CopyTextClipboardWriteError({ + target, + cause, + }), + ); + } + })(); + + void (async () => { + try { + if (feedback === "selection") { + await Haptics.selectionAsync(); + } else { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + } catch (cause) { + console.error( + new CopyTextHapticFeedbackError({ + target, + feedback, + cause, + }), + ); + } + })(); } diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index fec255355a5..abc0db79b6c 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { reportFailure: false, }); - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e507a2f7709..9746857a8ca 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ reportFailure: false, }); const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "plan", onError: (error) => { toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 6df3367c642..92d8d3f6827 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -32,6 +32,7 @@ import { } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; import { usePrimaryEnvironment } from "../../state/environments"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -246,16 +247,10 @@ function DiagnosticsTable({ } function TraceIdCell({ traceId }: { traceId: string }) { - const [copied, setCopied] = useState(false); - const copyTraceId = useCallback(() => { - void navigator.clipboard - ?.writeText(traceId) - .then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1_200); - }) - .catch(() => undefined); - }, [traceId]); + const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ + target: "trace ID", + timeout: 1_200, + }); return (
@@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { type="button" className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" aria-label={copied ? "Copied trace ID" : "Copy trace ID"} - onClick={copyTraceId} + onClick={() => copyToClipboard(traceId)} > diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 2c2a554871a..d48cda453b8 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -117,7 +117,7 @@ function handleToastDismissClick( } function CopyErrorButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); const label = isCopied ? "Copied error" : "Copy error"; return ( diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts new file mode 100644 index 00000000000..ccac333cd48 --- /dev/null +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + ClipboardApiUnavailableError, + ClipboardWriteError, + writeTextToClipboard, +} from "./useCopyToClipboard"; + +describe("writeTextToClipboard", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reports unavailable clipboard support with structural context", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + + const error = await writeTextToClipboard("plan contents", "plan").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(ClipboardApiUnavailableError); + expect(error).toMatchObject({ + target: "plan", + }); + expect((error as Error).message).not.toContain("plan contents"); + }); + + it("preserves the exact clipboard failure without exposing copied contents", async () => { + const cause = new Error("browser clipboard failure"); + const writeText = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( + () => undefined, + (failure: unknown) => failure, + ); + + expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); + expect(error).toBeInstanceOf(ClipboardWriteError); + expect(error).toMatchObject({ + target: "error-message", + cause, + }); + expect((error as Error).message).not.toContain("secret clipboard contents"); + }); + + it("keeps empty values as a no-op when clipboard support is available", async () => { + const writeText = vi.fn(); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index d1feb621159..0129f2d6593 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -1,11 +1,61 @@ import * as React from "react"; +import * as Schema from "effect/Schema"; + +export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass()( + "ClipboardApiUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while copying ${this.target}.`; + } +} + +export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardWriteError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to copy ${this.target} to the clipboard.`; + } +} + +export async function writeTextToClipboard(value: string, target = "text") { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.writeText + ) { + throw new ClipboardApiUnavailableError({ + target, + }); + } + + if (!value) return false; + + try { + await navigator.clipboard.writeText(value); + return true; + } catch (cause) { + throw new ClipboardWriteError({ + target, + cause, + }); + } +} export function useCopyToClipboard({ timeout = 2000, + target = "text", onCopy, onError, }: { timeout?: number; + target?: string; onCopy?: (ctx: TContext) => void; onError?: (error: Error, ctx: TContext) => void; } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { @@ -13,22 +63,18 @@ export function useCopyToClipboard({ const timeoutIdRef = React.useRef(null); const onCopyRef = React.useRef(onCopy); const onErrorRef = React.useRef(onError); + const targetRef = React.useRef(target); const timeoutRef = React.useRef(timeout); onCopyRef.current = onCopy; onErrorRef.current = onError; + targetRef.current = target; timeoutRef.current = timeout; const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); - return; - } - - if (!value) return; - - navigator.clipboard.writeText(value).then( - () => { + void writeTextToClipboard(value, targetRef.current).then( + (didCopy) => { + if (!didCopy) return; if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } @@ -44,11 +90,8 @@ export function useCopyToClipboard({ } }, (error) => { - if (onErrorRef.current) { - onErrorRef.current(error, ctx); - } else { - console.error(error); - } + console.error(error); + onErrorRef.current?.(error, ctx); }, ); }, []);