| author | |
| committer | |
| log | b88873508e8dedbff772b9835604d66c8c72c17c |
| tree | acc843e190dff2cbc20275ab5fbdcf5132bbc700 |
| parent | 58053d146b1ea7201884867d1543ccbbc4c05ffd |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>12 files changed, 261 insertions(+), 39 deletions(-)
apps/mobile/src/app/settings/environments.tsx+2-2| ... | ... | @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { |
| 397 | 397 | className={cn("text-xs leading-[16px] underline", statusClassName)} |
| 398 | 398 | onLongPress={(event) => { |
| 399 | 399 | event.stopPropagation(); |
| 400 | copyTextWithHaptic(errorTraceId); | |
| 400 | copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); | |
| 401 | 401 | }} |
| 402 | 402 | onPress={(event) => { |
| 403 | 403 | event.stopPropagation(); |
| ... | ... | @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { |
| 441 | 441 | <Pressable |
| 442 | 442 | accessibilityRole="button" |
| 443 | 443 | onPress={() => { |
| 444 | copyTextWithHaptic(props.traceId); | |
| 444 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); | |
| 445 | 445 | }} |
| 446 | 446 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" |
| 447 | 447 | > |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1| ... | ... | @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { |
| 101 | 101 | className="underline" |
| 102 | 102 | onLongPress={(event) => { |
| 103 | 103 | event.stopPropagation(); |
| 104 | copyTextWithHaptic(statusTraceId); | |
| 104 | copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); | |
| 105 | 105 | }} |
| 106 | 106 | onPress={(event) => { |
| 107 | 107 | event.stopPropagation(); |
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+5-1| ... | ... | @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { |
| 85 | 85 | accessibilityHint="Copies the trace ID" |
| 86 | 86 | accessibilityRole="button" |
| 87 | 87 | className="underline decoration-dotted" |
| 88 | onPress={() => copyTextWithHaptic(props.connection.traceId!)} | |
| 88 | onPress={() => | |
| 89 | copyTextWithHaptic(props.connection.traceId!, { | |
| 90 | target: "connection-trace-id", | |
| 91 | }) | |
| 92 | } | |
| 89 | 93 | > |
| 90 | 94 | {props.connection.traceId} |
| 91 | 95 | </Text> |
apps/mobile/src/features/threads/ThreadFeed.tsx+5-3| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import * as Clipboard from "expo-clipboard"; | |
| 2 | 1 | import * as Haptics from "expo-haptics"; |
| 3 | 2 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| ... | ... | @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; |
| 31 | 30 | import ImageViewing from "react-native-image-viewing"; |
| 32 | 31 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | 32 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 34 | 34 | import { |
| 35 | 35 | hasNativeSelectableMarkdownText, |
| 36 | 36 | SelectableMarkdownText, |
| ... | ... | @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1321 | 1321 | }, []); |
| 1322 | 1322 | |
| 1323 | 1323 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { |
| 1324 | void Clipboard.setStringAsync(value); | |
| 1325 | void Haptics.selectionAsync(); | |
| 1324 | copyTextWithHaptic(value, { | |
| 1325 | target: "thread-work-row", | |
| 1326 | feedback: "selection", | |
| 1327 | }); | |
| 1326 | 1328 | setInteractionState((current) => ({ ...current, copiedRowId: rowId })); |
| 1327 | 1329 | if (copyFeedbackTimeoutRef.current) { |
| 1328 | 1330 | clearTimeout(copyFeedbackTimeoutRef.current); |
apps/mobile/src/lib/copyTextWithHaptic.test.ts+58-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | 2 | |
| 3 | 3 | const mocks = vi.hoisted(() => ({ |
| 4 | 4 | impactAsync: vi.fn(), |
| 5 | selectionAsync: vi.fn(), | |
| 5 | 6 | setStringAsync: vi.fn(), |
| 6 | 7 | })); |
| 7 | 8 | |
| ... | ... | @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ |
| 14 | 15 | Light: "light", |
| 15 | 16 | }, |
| 16 | 17 | impactAsync: mocks.impactAsync, |
| 18 | selectionAsync: mocks.selectionAsync, | |
| 17 | 19 | })); |
| 18 | 20 | |
| 19 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | |
| 21 | import { | |
| 22 | CopyTextClipboardWriteError, | |
| 23 | CopyTextHapticFeedbackError, | |
| 24 | copyTextWithHaptic, | |
| 25 | } from "./copyTextWithHaptic"; | |
| 20 | 26 | |
| 21 | 27 | describe("copyTextWithHaptic", () => { |
| 22 | 28 | beforeEach(() => { |
| 23 | 29 | vi.clearAllMocks(); |
| 24 | 30 | mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined)); |
| 25 | 31 | mocks.impactAsync.mockResolvedValue(undefined); |
| 32 | mocks.selectionAsync.mockResolvedValue(undefined); | |
| 33 | }); | |
| 34 | ||
| 35 | afterEach(() => { | |
| 36 | vi.restoreAllMocks(); | |
| 26 | 37 | }); |
| 27 | 38 | |
| 28 | 39 | it("triggers haptic feedback without waiting for the clipboard promise", () => { |
| ... | ... | @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { |
| 31 | 42 | expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); |
| 32 | 43 | expect(mocks.impactAsync).toHaveBeenCalledWith("light"); |
| 33 | 44 | }); |
| 45 | ||
| 46 | it("preserves selection feedback for thread work rows", () => { | |
| 47 | copyTextWithHaptic("work output", { | |
| 48 | target: "thread-work-row", | |
| 49 | feedback: "selection", | |
| 50 | }); | |
| 51 | ||
| 52 | expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); | |
| 53 | expect(mocks.selectionAsync).toHaveBeenCalledOnce(); | |
| 54 | expect(mocks.impactAsync).not.toHaveBeenCalled(); | |
| 55 | }); | |
| 56 | ||
| 57 | it("reports structured failures without including clipboard contents", async () => { | |
| 58 | const clipboardCause = new Error("native clipboard failure"); | |
| 59 | const hapticCause = new Error("native haptic failure"); | |
| 60 | const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); | |
| 61 | mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); | |
| 62 | mocks.impactAsync.mockRejectedValueOnce(hapticCause); | |
| 63 | ||
| 64 | copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); | |
| 65 | ||
| 66 | await vi.waitFor(() => { | |
| 67 | expect(consoleError).toHaveBeenCalledTimes(2); | |
| 68 | }); | |
| 69 | ||
| 70 | const failures = consoleError.mock.calls.map(([failure]) => failure); | |
| 71 | const clipboardError = failures.find( | |
| 72 | (failure) => failure instanceof CopyTextClipboardWriteError, | |
| 73 | ); | |
| 74 | expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); | |
| 75 | expect(clipboardError).toMatchObject({ | |
| 76 | target: "connection-trace-id", | |
| 77 | cause: clipboardCause, | |
| 78 | }); | |
| 79 | expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); | |
| 80 | ||
| 81 | const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); | |
| 82 | expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); | |
| 83 | expect(hapticError).toMatchObject({ | |
| 84 | target: "connection-trace-id", | |
| 85 | feedback: "light-impact", | |
| 86 | cause: hapticCause, | |
| 87 | }); | |
| 88 | expect((hapticError as Error).message).not.toContain("secret clipboard contents"); | |
| 89 | }); | |
| 34 | 90 | }); |
apps/mobile/src/lib/copyTextWithHaptic.ts+66-3| ... | ... | @@ -1,7 +1,70 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 1 | 2 | import * as Clipboard from "expo-clipboard"; |
| 2 | 3 | import * as Haptics from "expo-haptics"; |
| 3 | 4 | |
| 4 | export function copyTextWithHaptic(value: string): void { | |
| 5 | void Clipboard.setStringAsync(value); | |
| 6 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | |
| 5 | export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass<CopyTextClipboardWriteError>()( | |
| 6 | "CopyTextClipboardWriteError", | |
| 7 | { | |
| 8 | target: Schema.String, | |
| 9 | cause: Schema.Defect(), | |
| 10 | }, | |
| 11 | ) { | |
| 12 | override get message(): string { | |
| 13 | return `Failed to copy ${this.target} to the clipboard.`; | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTextHapticFeedbackError>()( | |
| 18 | "CopyTextHapticFeedbackError", | |
| 19 | { | |
| 20 | target: Schema.String, | |
| 21 | feedback: Schema.Literals(["light-impact", "selection"]), | |
| 22 | cause: Schema.Defect(), | |
| 23 | }, | |
| 24 | ) { | |
| 25 | override get message(): string { | |
| 26 | return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | export function copyTextWithHaptic( | |
| 31 | value: string, | |
| 32 | options: { | |
| 33 | readonly target?: string; | |
| 34 | readonly feedback?: "light-impact" | "selection"; | |
| 35 | } = {}, | |
| 36 | ): void { | |
| 37 | const target = options.target ?? "text"; | |
| 38 | const feedback = options.feedback ?? "light-impact"; | |
| 39 | ||
| 40 | void (async () => { | |
| 41 | try { | |
| 42 | await Clipboard.setStringAsync(value); | |
| 43 | } catch (cause) { | |
| 44 | console.error( | |
| 45 | new CopyTextClipboardWriteError({ | |
| 46 | target, | |
| 47 | cause, | |
| 48 | }), | |
| 49 | ); | |
| 50 | } | |
| 51 | })(); | |
| 52 | ||
| 53 | void (async () => { | |
| 54 | try { | |
| 55 | if (feedback === "selection") { | |
| 56 | await Haptics.selectionAsync(); | |
| 57 | } else { | |
| 58 | await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | |
| 59 | } | |
| 60 | } catch (cause) { | |
| 61 | console.error( | |
| 62 | new CopyTextHapticFeedbackError({ | |
| 63 | target, | |
| 64 | feedback, | |
| 65 | cause, | |
| 66 | }), | |
| 67 | ); | |
| 68 | } | |
| 69 | })(); | |
| 7 | 70 | } |
apps/web/src/components/PlanSidebar.tsx+1-1| ... | ... | @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 83 | 83 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { |
| 84 | 84 | reportFailure: false, |
| 85 | 85 | }); |
| 86 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | |
| 86 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); | |
| 87 | 87 | |
| 88 | 88 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; |
| 89 | 89 | const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; |
apps/web/src/components/chat/ProposedPlanCard.tsx+1| ... | ... | @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 54 | 54 | reportFailure: false, |
| 55 | 55 | }); |
| 56 | 56 | const { copyToClipboard, isCopied } = useCopyToClipboard({ |
| 57 | target: "plan", | |
| 57 | 58 | onError: (error) => { |
| 58 | 59 | toastManager.add( |
| 59 | 60 | stackedThreadToast({ |
apps/web/src/components/settings/DiagnosticsSettings.tsx+6-11| ... | ... | @@ -32,6 +32,7 @@ import { |
| 32 | 32 | } from "../../state/server"; |
| 33 | 33 | import { shellEnvironment } from "../../state/shell"; |
| 34 | 34 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 35 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 35 | 36 | import { Button } from "../ui/button"; |
| 36 | 37 | import { ScrollArea } from "../ui/scroll-area"; |
| 37 | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -246,16 +247,10 @@ function DiagnosticsTable({ |
| 246 | 247 | } |
| 247 | 248 | |
| 248 | 249 | function TraceIdCell({ traceId }: { traceId: string }) { |
| 249 | const [copied, setCopied] = useState(false); | |
| 250 | const copyTraceId = useCallback(() => { | |
| 251 | void navigator.clipboard | |
| 252 | ?.writeText(traceId) | |
| 253 | .then(() => { | |
| 254 | setCopied(true); | |
| 255 | window.setTimeout(() => setCopied(false), 1_200); | |
| 256 | }) | |
| 257 | .catch(() => undefined); | |
| 258 | }, [traceId]); | |
| 250 | const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ | |
| 251 | target: "trace ID", | |
| 252 | timeout: 1_200, | |
| 253 | }); | |
| 259 | 254 | |
| 260 | 255 | return ( |
| 261 | 256 | <div className="flex w-full min-w-0 max-w-full items-center gap-2"> |
| ... | ... | @@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { |
| 281 | 276 | type="button" |
| 282 | 277 | className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" |
| 283 | 278 | aria-label={copied ? "Copied trace ID" : "Copy trace ID"} |
| 284 | onClick={copyTraceId} | |
| 279 | onClick={() => copyToClipboard(traceId)} | |
| 285 | 280 | > |
| 286 | 281 | <CopyIcon className="size-3" /> |
| 287 | 282 | </button> |
apps/web/src/components/ui/toast.tsx+1-1| ... | ... | @@ -117,7 +117,7 @@ function handleToastDismissClick( |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | function CopyErrorButton({ text }: { text: string }) { |
| 120 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | |
| 120 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); | |
| 121 | 121 | const label = isCopied ? "Copied error" : "Copy error"; |
| 122 | 122 | |
| 123 | 123 | return ( |
apps/web/src/hooks/useCopyToClipboard.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | ClipboardApiUnavailableError, | |
| 5 | ClipboardWriteError, | |
| 6 | writeTextToClipboard, | |
| 7 | } from "./useCopyToClipboard"; | |
| 8 | ||
| 9 | describe("writeTextToClipboard", () => { | |
| 10 | afterEach(() => { | |
| 11 | vi.unstubAllGlobals(); | |
| 12 | }); | |
| 13 | ||
| 14 | it("reports unavailable clipboard support with structural context", async () => { | |
| 15 | vi.stubGlobal("window", {}); | |
| 16 | vi.stubGlobal("navigator", {}); | |
| 17 | ||
| 18 | const error = await writeTextToClipboard("plan contents", "plan").then( | |
| 19 | () => undefined, | |
| 20 | (cause: unknown) => cause, | |
| 21 | ); | |
| 22 | ||
| 23 | expect(error).toBeInstanceOf(ClipboardApiUnavailableError); | |
| 24 | expect(error).toMatchObject({ | |
| 25 | target: "plan", | |
| 26 | }); | |
| 27 | expect((error as Error).message).not.toContain("plan contents"); | |
| 28 | }); | |
| 29 | ||
| 30 | it("preserves the exact clipboard failure without exposing copied contents", async () => { | |
| 31 | const cause = new Error("browser clipboard failure"); | |
| 32 | const writeText = vi.fn().mockRejectedValue(cause); | |
| 33 | vi.stubGlobal("window", {}); | |
| 34 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | |
| 35 | ||
| 36 | const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( | |
| 37 | () => undefined, | |
| 38 | (failure: unknown) => failure, | |
| 39 | ); | |
| 40 | ||
| 41 | expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); | |
| 42 | expect(error).toBeInstanceOf(ClipboardWriteError); | |
| 43 | expect(error).toMatchObject({ | |
| 44 | target: "error-message", | |
| 45 | cause, | |
| 46 | }); | |
| 47 | expect((error as Error).message).not.toContain("secret clipboard contents"); | |
| 48 | }); | |
| 49 | ||
| 50 | it("keeps empty values as a no-op when clipboard support is available", async () => { | |
| 51 | const writeText = vi.fn(); | |
| 52 | vi.stubGlobal("window", {}); | |
| 53 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | |
| 54 | ||
| 55 | await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); | |
| 56 | expect(writeText).not.toHaveBeenCalled(); | |
| 57 | }); | |
| 58 | }); |
apps/web/src/hooks/useCopyToClipboard.ts+57-14| ... | ... | @@ -1,11 +1,61 @@ |
| 1 | 1 | import * as React from "react"; |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass<ClipboardApiUnavailableError>()( | |
| 5 | "ClipboardApiUnavailableError", | |
| 6 | { | |
| 7 | target: Schema.String, | |
| 8 | }, | |
| 9 | ) { | |
| 10 | override get message(): string { | |
| 11 | return `Clipboard API is unavailable while copying ${this.target}.`; | |
| 12 | } | |
| 13 | } | |
| 14 | ||
| 15 | export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteError>()( | |
| 16 | "ClipboardWriteError", | |
| 17 | { | |
| 18 | target: Schema.String, | |
| 19 | cause: Schema.Defect(), | |
| 20 | }, | |
| 21 | ) { | |
| 22 | override get message(): string { | |
| 23 | return `Failed to copy ${this.target} to the clipboard.`; | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | export async function writeTextToClipboard(value: string, target = "text") { | |
| 28 | if ( | |
| 29 | typeof window === "undefined" || | |
| 30 | typeof navigator === "undefined" || | |
| 31 | !navigator.clipboard?.writeText | |
| 32 | ) { | |
| 33 | throw new ClipboardApiUnavailableError({ | |
| 34 | target, | |
| 35 | }); | |
| 36 | } | |
| 37 | ||
| 38 | if (!value) return false; | |
| 39 | ||
| 40 | try { | |
| 41 | await navigator.clipboard.writeText(value); | |
| 42 | return true; | |
| 43 | } catch (cause) { | |
| 44 | throw new ClipboardWriteError({ | |
| 45 | target, | |
| 46 | cause, | |
| 47 | }); | |
| 48 | } | |
| 49 | } | |
| 2 | 50 | |
| 3 | 51 | export function useCopyToClipboard<TContext = void>({ |
| 4 | 52 | timeout = 2000, |
| 53 | target = "text", | |
| 5 | 54 | onCopy, |
| 6 | 55 | onError, |
| 7 | 56 | }: { |
| 8 | 57 | timeout?: number; |
| 58 | target?: string; | |
| 9 | 59 | onCopy?: (ctx: TContext) => void; |
| 10 | 60 | onError?: (error: Error, ctx: TContext) => void; |
| 11 | 61 | } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { |
| ... | ... | @@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({ |
| 13 | 63 | const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null); |
| 14 | 64 | const onCopyRef = React.useRef(onCopy); |
| 15 | 65 | const onErrorRef = React.useRef(onError); |
| 66 | const targetRef = React.useRef(target); | |
| 16 | 67 | const timeoutRef = React.useRef(timeout); |
| 17 | 68 | |
| 18 | 69 | onCopyRef.current = onCopy; |
| 19 | 70 | onErrorRef.current = onError; |
| 71 | targetRef.current = target; | |
| 20 | 72 | timeoutRef.current = timeout; |
| 21 | 73 | |
| 22 | 74 | const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { |
| 23 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { | |
| 24 | onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); | |
| 25 | return; | |
| 26 | } | |
| 27 | ||
| 28 | if (!value) return; | |
| 29 | ||
| 30 | navigator.clipboard.writeText(value).then( | |
| 31 | () => { | |
| 75 | void writeTextToClipboard(value, targetRef.current).then( | |
| 76 | (didCopy) => { | |
| 77 | if (!didCopy) return; | |
| 32 | 78 | if (timeoutIdRef.current) { |
| 33 | 79 | clearTimeout(timeoutIdRef.current); |
| 34 | 80 | } |
| ... | ... | @@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({ |
| 44 | 90 | } |
| 45 | 91 | }, |
| 46 | 92 | (error) => { |
| 47 | if (onErrorRef.current) { | |
| 48 | onErrorRef.current(error, ctx); | |
| 49 | } else { | |
| 50 | console.error(error); | |
| 51 | } | |
| 93 | console.error(error); | |
| 94 | onErrorRef.current?.(error, ctx); | |
| 52 | 95 | }, |
| 53 | 96 | ); |
| 54 | 97 | }, []); |