authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:59-07:00
logb88873508e8dedbff772b9835604d66c8c72c17c
treeacc843e190dff2cbc20275ab5fbdcf5132bbc700
parent58053d146b1ea7201884867d1543ccbbc4c05ffd
signature Signed by PGP key B5690EEEBB952194

[codex] Structure cross-client clipboard failures (#3361)

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: {
397397 className={cn("text-xs leading-[16px] underline", statusClassName)}
398398 onLongPress={(event) => {
399399 event.stopPropagation();
400 copyTextWithHaptic(errorTraceId);
400 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
401401 }}
402402 onPress={(event) => {
403403 event.stopPropagation();
......@@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) {
441441 <Pressable
442442 accessibilityRole="button"
443443 onPress={() => {
444 copyTextWithHaptic(props.traceId);
444 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
445445 }}
446446 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
447447 >
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1
......@@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: {
101101 className="underline"
102102 onLongPress={(event) => {
103103 event.stopPropagation();
104 copyTextWithHaptic(statusTraceId);
104 copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" });
105105 }}
106106 onPress={(event) => {
107107 event.stopPropagation();
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+5-1
......@@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: {
8585 accessibilityHint="Copies the trace ID"
8686 accessibilityRole="button"
8787 className="underline decoration-dotted"
88 onPress={() => copyTextWithHaptic(props.connection.traceId!)}
88 onPress={() =>
89 copyTextWithHaptic(props.connection.traceId!, {
90 target: "connection-trace-id",
91 })
92 }
8993 >
9094 {props.connection.traceId}
9195 </Text>
apps/mobile/src/features/threads/ThreadFeed.tsx+5-3
......@@ -1,4 +1,3 @@
1import * as Clipboard from "expo-clipboard";
21import * as Haptics from "expo-haptics";
32import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
43import { type LegendListRef } from "@legendapp/list/react-native";
......@@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler";
3130import ImageViewing from "react-native-image-viewing";
3231import { useSafeAreaInsets } from "react-native-safe-area-context";
3332import { useThemeColor } from "../../lib/useThemeColor";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3434import {
3535 hasNativeSelectableMarkdownText,
3636 SelectableMarkdownText,
......@@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13211321 }, []);
13221322
13231323 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 });
13261328 setInteractionState((current) => ({ ...current, copiedRowId: rowId }));
13271329 if (copyFeedbackTimeoutRef.current) {
13281330 clearTimeout(copyFeedbackTimeoutRef.current);
apps/mobile/src/lib/copyTextWithHaptic.test.ts+58-2
......@@ -1,7 +1,8 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33const mocks = vi.hoisted(() => ({
44 impactAsync: vi.fn(),
5 selectionAsync: vi.fn(),
56 setStringAsync: vi.fn(),
67}));
78
......@@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({
1415 Light: "light",
1516 },
1617 impactAsync: mocks.impactAsync,
18 selectionAsync: mocks.selectionAsync,
1719}));
1820
19import { copyTextWithHaptic } from "./copyTextWithHaptic";
21import {
22 CopyTextClipboardWriteError,
23 CopyTextHapticFeedbackError,
24 copyTextWithHaptic,
25} from "./copyTextWithHaptic";
2026
2127describe("copyTextWithHaptic", () => {
2228 beforeEach(() => {
2329 vi.clearAllMocks();
2430 mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined));
2531 mocks.impactAsync.mockResolvedValue(undefined);
32 mocks.selectionAsync.mockResolvedValue(undefined);
33 });
34
35 afterEach(() => {
36 vi.restoreAllMocks();
2637 });
2738
2839 it("triggers haptic feedback without waiting for the clipboard promise", () => {
......@@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => {
3142 expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");
3243 expect(mocks.impactAsync).toHaveBeenCalledWith("light");
3344 });
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 });
3490});
apps/mobile/src/lib/copyTextWithHaptic.ts+66-3
......@@ -1,7 +1,70 @@
1import * as Schema from "effect/Schema";
12import * as Clipboard from "expo-clipboard";
23import * as Haptics from "expo-haptics";
34
4export function copyTextWithHaptic(value: string): void {
5 void Clipboard.setStringAsync(value);
6 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
5export 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
17export 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
30export 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 })();
770}
apps/web/src/components/PlanSidebar.tsx+1-1
......@@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({
8383 const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
8484 reportFailure: false,
8585 });
86 const { copyToClipboard, isCopied } = useCopyToClipboard();
86 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" });
8787
8888 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
8989 const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
apps/web/src/components/chat/ProposedPlanCard.tsx+1
......@@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
5454 reportFailure: false,
5555 });
5656 const { copyToClipboard, isCopied } = useCopyToClipboard({
57 target: "plan",
5758 onError: (error) => {
5859 toastManager.add(
5960 stackedThreadToast({
apps/web/src/components/settings/DiagnosticsSettings.tsx+6-11
......@@ -32,6 +32,7 @@ import {
3232} from "../../state/server";
3333import { shellEnvironment } from "../../state/shell";
3434import { usePrimaryEnvironment } from "../../state/environments";
35import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
3536import { Button } from "../ui/button";
3637import { ScrollArea } from "../ui/scroll-area";
3738import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -246,16 +247,10 @@ function DiagnosticsTable({
246247}
247248
248249function 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 });
259254
260255 return (
261256 <div className="flex w-full min-w-0 max-w-full items-center gap-2">
......@@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) {
281276 type="button"
282277 className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
283278 aria-label={copied ? "Copied trace ID" : "Copy trace ID"}
284 onClick={copyTraceId}
279 onClick={() => copyToClipboard(traceId)}
285280 >
286281 <CopyIcon className="size-3" />
287282 </button>
apps/web/src/components/ui/toast.tsx+1-1
......@@ -117,7 +117,7 @@ function handleToastDismissClick(
117117}
118118
119119function CopyErrorButton({ text }: { text: string }) {
120 const { copyToClipboard, isCopied } = useCopyToClipboard();
120 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" });
121121 const label = isCopied ? "Copied error" : "Copy error";
122122
123123 return (
apps/web/src/hooks/useCopyToClipboard.test.ts created+58
......@@ -0,0 +1,58 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 ClipboardApiUnavailableError,
5 ClipboardWriteError,
6 writeTextToClipboard,
7} from "./useCopyToClipboard";
8
9describe("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 @@
11import * as React from "react";
2import * as Schema from "effect/Schema";
3
4export 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
15export 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
27export 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}
250
351export function useCopyToClipboard<TContext = void>({
452 timeout = 2000,
53 target = "text",
554 onCopy,
655 onError,
756}: {
857 timeout?: number;
58 target?: string;
959 onCopy?: (ctx: TContext) => void;
1060 onError?: (error: Error, ctx: TContext) => void;
1161} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
......@@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({
1363 const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
1464 const onCopyRef = React.useRef(onCopy);
1565 const onErrorRef = React.useRef(onError);
66 const targetRef = React.useRef(target);
1667 const timeoutRef = React.useRef(timeout);
1768
1869 onCopyRef.current = onCopy;
1970 onErrorRef.current = onError;
71 targetRef.current = target;
2072 timeoutRef.current = timeout;
2173
2274 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;
3278 if (timeoutIdRef.current) {
3379 clearTimeout(timeoutIdRef.current);
3480 }
......@@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({
4490 }
4591 },
4692 (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);
5295 },
5396 );
5497 }, []);