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: {...@@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: {
397 className={cn("text-xs leading-[16px] underline", statusClassName)}397 className={cn("text-xs leading-[16px] underline", statusClassName)}
398 onLongPress={(event) => {398 onLongPress={(event) => {
399 event.stopPropagation();399 event.stopPropagation();
400 copyTextWithHaptic(errorTraceId);400 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
401 }}401 }}
402 onPress={(event) => {402 onPress={(event) => {
403 event.stopPropagation();403 event.stopPropagation();
...@@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) {...@@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) {
441 <Pressable441 <Pressable
442 accessibilityRole="button"442 accessibilityRole="button"
443 onPress={() => {443 onPress={() => {
444 copyTextWithHaptic(props.traceId);444 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
445 }}445 }}
446 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"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,7 +101,7 @@ export function ConnectionEnvironmentRow(props: {
101 className="underline"101 className="underline"
102 onLongPress={(event) => {102 onLongPress={(event) => {
103 event.stopPropagation();103 event.stopPropagation();
104 copyTextWithHaptic(statusTraceId);104 copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" });
105 }}105 }}
106 onPress={(event) => {106 onPress={(event) => {
107 event.stopPropagation();107 event.stopPropagation();
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+5-1
...@@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: {...@@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: {
85 accessibilityHint="Copies the trace ID"85 accessibilityHint="Copies the trace ID"
86 accessibilityRole="button"86 accessibilityRole="button"
87 className="underline decoration-dotted"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 {props.connection.traceId}94 {props.connection.traceId}
91 </Text>95 </Text>
apps/mobile/src/features/threads/ThreadFeed.tsx+5-3
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
3import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";2import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard";
4import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
...@@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler";...@@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler";
31import ImageViewing from "react-native-image-viewing";30import ImageViewing from "react-native-image-viewing";
32import { useSafeAreaInsets } from "react-native-safe-area-context";31import { useSafeAreaInsets } from "react-native-safe-area-context";
33import { useThemeColor } from "../../lib/useThemeColor";32import { useThemeColor } from "../../lib/useThemeColor";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
34import {34import {
35 hasNativeSelectableMarkdownText,35 hasNativeSelectableMarkdownText,
36 SelectableMarkdownText,36 SelectableMarkdownText,
...@@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1321 }, []);1321 }, []);
13221322
1323 const onCopyWorkRow = useCallback((rowId: string, value: string) => {1323 const onCopyWorkRow = useCallback((rowId: string, value: string) => {
1324 void Clipboard.setStringAsync(value);1324 copyTextWithHaptic(value, {
1325 void Haptics.selectionAsync();1325 target: "thread-work-row",
1326 feedback: "selection",
1327 });
1326 setInteractionState((current) => ({ ...current, copiedRowId: rowId }));1328 setInteractionState((current) => ({ ...current, copiedRowId: rowId }));
1327 if (copyFeedbackTimeoutRef.current) {1329 if (copyFeedbackTimeoutRef.current) {
1328 clearTimeout(copyFeedbackTimeoutRef.current);1330 clearTimeout(copyFeedbackTimeoutRef.current);
apps/mobile/src/lib/copyTextWithHaptic.test.ts+58-2
...@@ -1,7 +1,8 @@...@@ -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
3const mocks = vi.hoisted(() => ({3const mocks = vi.hoisted(() => ({
4 impactAsync: vi.fn(),4 impactAsync: vi.fn(),
5 selectionAsync: vi.fn(),
5 setStringAsync: vi.fn(),6 setStringAsync: vi.fn(),
6}));7}));
78
...@@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({...@@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({
14 Light: "light",15 Light: "light",
15 },16 },
16 impactAsync: mocks.impactAsync,17 impactAsync: mocks.impactAsync,
18 selectionAsync: mocks.selectionAsync,
17}));19}));
1820
19import { copyTextWithHaptic } from "./copyTextWithHaptic";21import {
22 CopyTextClipboardWriteError,
23 CopyTextHapticFeedbackError,
24 copyTextWithHaptic,
25} from "./copyTextWithHaptic";
2026
21describe("copyTextWithHaptic", () => {27describe("copyTextWithHaptic", () => {
22 beforeEach(() => {28 beforeEach(() => {
23 vi.clearAllMocks();29 vi.clearAllMocks();
24 mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined));30 mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined));
25 mocks.impactAsync.mockResolvedValue(undefined);31 mocks.impactAsync.mockResolvedValue(undefined);
32 mocks.selectionAsync.mockResolvedValue(undefined);
33 });
34
35 afterEach(() => {
36 vi.restoreAllMocks();
26 });37 });
2738
28 it("triggers haptic feedback without waiting for the clipboard promise", () => {39 it("triggers haptic feedback without waiting for the clipboard promise", () => {
...@@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => {...@@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => {
31 expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");42 expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123");
32 expect(mocks.impactAsync).toHaveBeenCalledWith("light");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,7 +1,70 @@
1import * as Schema from "effect/Schema";
1import * as Clipboard from "expo-clipboard";2import * as Clipboard from "expo-clipboard";
2import * as Haptics from "expo-haptics";3import * as Haptics from "expo-haptics";
34
4export function copyTextWithHaptic(value: string): void {5export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass<CopyTextClipboardWriteError>()(
5 void Clipboard.setStringAsync(value);6 "CopyTextClipboardWriteError",
6 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);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 })();
7}70}
apps/web/src/components/PlanSidebar.tsx+1-1
...@@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({
83 const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {83 const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
84 reportFailure: false,84 reportFailure: false,
85 });85 });
86 const { copyToClipboard, isCopied } = useCopyToClipboard();86 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" });
8787
88 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;88 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
89 const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;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,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
54 reportFailure: false,54 reportFailure: false,
55 });55 });
56 const { copyToClipboard, isCopied } = useCopyToClipboard({56 const { copyToClipboard, isCopied } = useCopyToClipboard({
57 target: "plan",
57 onError: (error) => {58 onError: (error) => {
58 toastManager.add(59 toastManager.add(
59 stackedThreadToast({60 stackedThreadToast({
apps/web/src/components/settings/DiagnosticsSettings.tsx+6-11
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32} from "../../state/server";32} from "../../state/server";
33import { shellEnvironment } from "../../state/shell";33import { shellEnvironment } from "../../state/shell";
34import { usePrimaryEnvironment } from "../../state/environments";34import { usePrimaryEnvironment } from "../../state/environments";
35import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
35import { Button } from "../ui/button";36import { Button } from "../ui/button";
36import { ScrollArea } from "../ui/scroll-area";37import { ScrollArea } from "../ui/scroll-area";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -246,16 +247,10 @@ function DiagnosticsTable({...@@ -246,16 +247,10 @@ function DiagnosticsTable({
246}247}
247248
248function TraceIdCell({ traceId }: { traceId: string }) {249function TraceIdCell({ traceId }: { traceId: string }) {
249 const [copied, setCopied] = useState(false);250 const { copyToClipboard, isCopied: copied } = useCopyToClipboard({
250 const copyTraceId = useCallback(() => {251 target: "trace ID",
251 void navigator.clipboard252 timeout: 1_200,
252 ?.writeText(traceId)253 });
253 .then(() => {
254 setCopied(true);
255 window.setTimeout(() => setCopied(false), 1_200);
256 })
257 .catch(() => undefined);
258 }, [traceId]);
259254
260 return (255 return (
261 <div className="flex w-full min-w-0 max-w-full items-center gap-2">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,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) {
281 type="button"276 type="button"
282 className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"277 className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
283 aria-label={copied ? "Copied trace ID" : "Copy trace ID"}278 aria-label={copied ? "Copied trace ID" : "Copy trace ID"}
284 onClick={copyTraceId}279 onClick={() => copyToClipboard(traceId)}
285 >280 >
286 <CopyIcon className="size-3" />281 <CopyIcon className="size-3" />
287 </button>282 </button>
apps/web/src/components/ui/toast.tsx+1-1
...@@ -117,7 +117,7 @@ function handleToastDismissClick(...@@ -117,7 +117,7 @@ function handleToastDismissClick(
117}117}
118118
119function CopyErrorButton({ text }: { text: string }) {119function CopyErrorButton({ text }: { text: string }) {
120 const { copyToClipboard, isCopied } = useCopyToClipboard();120 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" });
121 const label = isCopied ? "Copied error" : "Copy error";121 const label = isCopied ? "Copied error" : "Copy error";
122122
123 return (123 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 @@...@@ -1,11 +1,61 @@
1import * as React from "react";1import * 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
3export function useCopyToClipboard<TContext = void>({51export function useCopyToClipboard<TContext = void>({
4 timeout = 2000,52 timeout = 2000,
53 target = "text",
5 onCopy,54 onCopy,
6 onError,55 onError,
7}: {56}: {
8 timeout?: number;57 timeout?: number;
58 target?: string;
9 onCopy?: (ctx: TContext) => void;59 onCopy?: (ctx: TContext) => void;
10 onError?: (error: Error, ctx: TContext) => void;60 onError?: (error: Error, ctx: TContext) => void;
11} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {61} = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } {
...@@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({...@@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({
13 const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);63 const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null);
14 const onCopyRef = React.useRef(onCopy);64 const onCopyRef = React.useRef(onCopy);
15 const onErrorRef = React.useRef(onError);65 const onErrorRef = React.useRef(onError);
66 const targetRef = React.useRef(target);
16 const timeoutRef = React.useRef(timeout);67 const timeoutRef = React.useRef(timeout);
1768
18 onCopyRef.current = onCopy;69 onCopyRef.current = onCopy;
19 onErrorRef.current = onError;70 onErrorRef.current = onError;
71 targetRef.current = target;
20 timeoutRef.current = timeout;72 timeoutRef.current = timeout;
2173
22 const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => {74 const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => {
23 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {75 void writeTextToClipboard(value, targetRef.current).then(
24 onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx);76 (didCopy) => {
25 return;77 if (!didCopy) return;
26 }
27
28 if (!value) return;
29
30 navigator.clipboard.writeText(value).then(
31 () => {
32 if (timeoutIdRef.current) {78 if (timeoutIdRef.current) {
33 clearTimeout(timeoutIdRef.current);79 clearTimeout(timeoutIdRef.current);
34 }80 }
...@@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({...@@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({
44 }90 }
45 },91 },
46 (error) => {92 (error) => {
47 if (onErrorRef.current) {93 console.error(error);
48 onErrorRef.current(error, ctx);94 onErrorRef.current?.(error, ctx);
49 } else {
50 console.error(error);
51 }
52 },95 },
53 );96 );
54 }, []);97 }, []);