diff --git a/.changeset/gentle-wolves-copy.md b/.changeset/gentle-wolves-copy.md new file mode 100644 index 0000000..dd38389 --- /dev/null +++ b/.changeset/gentle-wolves-copy.md @@ -0,0 +1,5 @@ +--- +"@codex-relay/mobile": patch +--- + +Make the relay setup and phone approval commands selectable and copyable. diff --git a/apps/mobile/src/components/chat/ChatScreen.tsx b/apps/mobile/src/components/chat/ChatScreen.tsx index 77f952b..70a081d 100644 --- a/apps/mobile/src/components/chat/ChatScreen.tsx +++ b/apps/mobile/src/components/chat/ChatScreen.tsx @@ -50,6 +50,7 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { ThemedText } from "@/components/themed-text"; +import { CopyableCommand } from "@/components/ui/copyable-command"; import { AppToast } from "@/components/ui/toast"; import { Colors, Fonts, Spacing } from "@/constants/theme"; import { activeThreadAfterRefresh } from "@/lib/active-thread-selection"; @@ -152,6 +153,7 @@ import { import { ChatShell } from "./ChatShell"; import type { ChatShellAction } from "./ChatShellHeader"; import { ConnectionBanner } from "./ConnectionBanner"; +import { approvalCommand } from "./pairing-commands"; import { EXPANDED_DRAWER_BREAKPOINT, THREE_PANE_LAYOUT_BREAKPOINT, @@ -2418,13 +2420,17 @@ export function ChatScreen({ initialPairingUrl }: ChatScreenProps = {}) { {pasteApprovalCode ? ( Approval code - {pasteApprovalCode} + + {pasteApprovalCode} + Run this on your computer: - - {approvalCommand(pasteApprovalCode, pasteApprovalServerUrl)} - + ) : null} @@ -2811,29 +2817,10 @@ function delay(ms: number) { }); } -function approvalCommand(approvalCode: string, serverUrl?: string) { - const port = approvalPort(serverUrl); - return port && port !== "8787" - ? `PORT=${port} npx codex-relay@latest approve ${approvalCode}` - : `npx codex-relay@latest approve ${approvalCode}`; -} - function approvalMessage(approvalCode: string, serverUrl?: string) { return `Run ${approvalCommand(approvalCode, serverUrl)} in the server terminal.`; } -function approvalPort(serverUrl?: string) { - if (!serverUrl) { - return undefined; - } - - try { - return new URL(serverUrl).port; - } catch { - return undefined; - } -} - function readScannedPayload(result: unknown) { if (!result || typeof result !== "object") { return undefined; diff --git a/apps/mobile/src/components/chat/ConnectionBanner.tsx b/apps/mobile/src/components/chat/ConnectionBanner.tsx index be502e1..318aec2 100644 --- a/apps/mobile/src/components/chat/ConnectionBanner.tsx +++ b/apps/mobile/src/components/chat/ConnectionBanner.tsx @@ -4,10 +4,12 @@ import { StyleSheet } from "react-native-unistyles"; import { ThemedText } from "@/components/themed-text"; import { Button } from "@/components/ui/button"; +import { CopyableCommand } from "@/components/ui/copyable-command"; import { Icon } from "@/components/ui/icon"; -import { Fonts } from "@/constants/theme"; import { workspaceName } from "@/lib/workspace-name"; +import { relayStartCommand } from "./pairing-commands"; + const tailscaleAppStoreUrl = "https://apps.apple.com/us/app/tailscale/id1470499037"; export function ConnectionBanner({ @@ -113,7 +115,7 @@ export function ConnectionBanner({ label="1" title="Start the relay" body="Open Terminal on your computer and run:" - command="npx codex-relay@latest" + command={relayStartCommand} /> {command ? ( - - - {command} - - + ) : null} {actionLabel && onAction ? ( { + it("uses the latest relay package for setup and default-port approval", () => { + expect(relayStartCommand).toBe("npx codex-relay@latest"); + expect(approvalCommand("ABCD-1234")).toBe("npx codex-relay@latest approve ABCD-1234"); + expect(approvalCommand("ABCD-1234", "http://192.168.1.4:8787")).toBe( + "npx codex-relay@latest approve ABCD-1234", + ); + }); + + it("includes a non-default relay port in the approval command", () => { + expect(approvalCommand("ABCD-1234", "http://192.168.1.4:9123")).toBe( + "PORT=9123 npx codex-relay@latest approve ABCD-1234", + ); + }); + + it("falls back to the default command for an invalid server URL", () => { + expect(approvalCommand("ABCD-1234", "not a URL")).toBe( + "npx codex-relay@latest approve ABCD-1234", + ); + }); +}); diff --git a/apps/mobile/src/components/chat/pairing-commands.ts b/apps/mobile/src/components/chat/pairing-commands.ts new file mode 100644 index 0000000..d60b76c --- /dev/null +++ b/apps/mobile/src/components/chat/pairing-commands.ts @@ -0,0 +1,20 @@ +export const relayStartCommand = "npx codex-relay@latest"; + +export function approvalCommand(approvalCode: string, serverUrl?: string) { + const port = approvalPort(serverUrl); + return port && port !== "8787" + ? `PORT=${port} ${relayStartCommand} approve ${approvalCode}` + : `${relayStartCommand} approve ${approvalCode}`; +} + +function approvalPort(serverUrl?: string) { + if (!serverUrl) { + return undefined; + } + + try { + return new URL(serverUrl).port; + } catch { + return undefined; + } +} diff --git a/apps/mobile/src/components/ui/copyable-command.tsx b/apps/mobile/src/components/ui/copyable-command.tsx new file mode 100644 index 0000000..04378bc --- /dev/null +++ b/apps/mobile/src/components/ui/copyable-command.tsx @@ -0,0 +1,129 @@ +import * as Clipboard from "expo-clipboard"; +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Pressable, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +import { ThemedText } from "@/components/themed-text"; +import { Icon } from "@/components/ui/icon"; +import { Fonts } from "@/constants/theme"; +import { hapticSelection } from "@/lib/haptics"; + +const copiedIndicatorDurationMs = 1400; + +export function CopyableCommand({ + command, + copyAccessibilityLabel = "Copy command", + style, + textStyle, +}: { + command: string; + copyAccessibilityLabel?: string; + style?: StyleProp; + textStyle?: StyleProp; +}) { + const [isCopied, setCopied] = useState(false); + const handleCopyPress = useCallback(() => { + hapticSelection(); + setCopied(true); + Clipboard.setStringAsync(command).catch((error: unknown) => { + setCopied(false); + Alert.alert("Copy failed", copyFailureMessage(error)); + }); + }, [command]); + + useEffect(() => { + if (!isCopied) { + return; + } + + const timer = setTimeout(() => setCopied(false), copiedIndicatorDurationMs); + return () => clearTimeout(timer); + }, [isCopied]); + + return ( + + + {command} + + [ + styles.copyButton, + isCopied && styles.copyButtonCopied, + pressed && styles.pressed, + ]} + > + + + {isCopied ? "Copied" : "Copy"} + + + + ); +} + +function copyFailureMessage(error: unknown) { + return error instanceof Error ? error.message : "The command could not be copied."; +} + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + backgroundColor: "rgba(0, 0, 0, 0.24)", + borderColor: "rgba(255, 255, 255, 0.08)", + borderRadius: 7, + borderWidth: 1, + flexDirection: "row", + gap: 8, + minWidth: 0, + paddingBottom: 5, + paddingLeft: 9, + paddingRight: 5, + paddingTop: 5, + }, + commandText: { + flex: 1, + fontFamily: Fonts.mono, + fontSize: 12, + lineHeight: 16, + minWidth: 0, + }, + copyButton: { + alignItems: "center", + alignSelf: "stretch", + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderColor: "rgba(255, 255, 255, 0.1)", + borderRadius: 6, + borderWidth: 1, + flexDirection: "row", + gap: 4, + justifyContent: "center", + minHeight: 30, + minWidth: 68, + paddingHorizontal: 7, + paddingVertical: 5, + }, + copyButtonCopied: { + backgroundColor: "rgba(86, 196, 134, 0.16)", + borderColor: "rgba(86, 196, 134, 0.3)", + }, + copyButtonText: { + color: "#F2F2F2", + fontSize: 11, + lineHeight: 14, + }, + pressed: { + opacity: 0.7, + }, +});