Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/src/app/settings/environments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) {
<Pressable
accessibilityRole="button"
onPress={() => {
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"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</Text>
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 60 additions & 2 deletions apps/mobile/src/lib/copyTextWithHaptic.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
}));

Expand All @@ -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<void>(() => undefined));
mocks.impactAsync.mockResolvedValue(undefined);
mocks.selectionAsync.mockResolvedValue(undefined);
});

afterEach(() => {
vi.restoreAllMocks();
});

it("triggers haptic feedback without waiting for the clipboard promise", () => {
Expand All @@ -31,4 +42,51 @@ 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({
operation: "write-clipboard",
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({
operation: "trigger-haptic-feedback",
target: "connection-trace-id",
feedback: "light-impact",
cause: hapticCause,
});
expect((hapticError as Error).message).not.toContain("secret clipboard contents");
});
});
73 changes: 70 additions & 3 deletions apps/mobile/src/lib/copyTextWithHaptic.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,74 @@
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>()(
"CopyTextClipboardWriteError",
{
operation: Schema.Literal("write-clipboard"),
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>()(
"CopyTextHapticFeedbackError",
{
operation: Schema.Literal("trigger-haptic-feedback"),
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({
operation: "write-clipboard",
target,
cause,
}),
);
}
})();

void (async () => {
try {
if (feedback === "selection") {
await Haptics.selectionAsync();
} else {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
} catch (cause) {
console.error(
new CopyTextHapticFeedbackError({
operation: "trigger-haptic-feedback",
target,
feedback,
cause,
}),
);
}
})();
}
2 changes: 1 addition & 1 deletion apps/web/src/components/PlanSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ProposedPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
reportFailure: false,
});
const { copyToClipboard, isCopied } = useCopyToClipboard({
target: "plan",
onError: (error) => {
toastManager.add(
stackedThreadToast({
Expand Down
17 changes: 6 additions & 11 deletions apps/web/src/components/settings/DiagnosticsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="flex w-full min-w-0 max-w-full items-center gap-2">
Expand All @@ -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)}
>
<CopyIcon className="size-3" />
</button>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/hooks/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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({
operation: "resolve-clipboard-api",
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({
operation: "write-clipboard",
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();
});
});
Loading
Loading