Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/gentle-wolves-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codex-relay/mobile": patch
---

Make the relay setup and phone approval commands selectable and copyable.
33 changes: 10 additions & 23 deletions apps/mobile/src/components/chat/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2418,13 +2420,17 @@ export function ChatScreen({ initialPairingUrl }: ChatScreenProps = {}) {
{pasteApprovalCode ? (
<View style={styles.manualApproval}>
<ThemedText type="smallBold">Approval code</ThemedText>
<ThemedText style={styles.manualApprovalCode}>{pasteApprovalCode}</ThemedText>
<ThemedText selectable style={styles.manualApprovalCode}>
{pasteApprovalCode}
</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Run this on your computer:
</ThemedText>
<ThemedText style={styles.manualApprovalCommand}>
{approvalCommand(pasteApprovalCode, pasteApprovalServerUrl)}
</ThemedText>
<CopyableCommand
command={approvalCommand(pasteApprovalCode, pasteApprovalServerUrl)}
copyAccessibilityLabel="Copy approval command"
textStyle={styles.manualApprovalCommand}
/>
</View>
) : null}
</View>
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 5 additions & 20 deletions apps/mobile/src/components/chat/ConnectionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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}
/>
<PairingStep
icon="workspace"
Expand Down Expand Up @@ -204,11 +206,7 @@ function PairingStep({
{body}
</ThemedText>
{command ? (
<View style={styles.commandBox}>
<ThemedText type="smallBold" style={styles.commandText}>
{command}
</ThemedText>
</View>
<CopyableCommand command={command} copyAccessibilityLabel="Copy relay start command" />
) : null}
{actionLabel && onAction ? (
<Pressable
Expand Down Expand Up @@ -368,19 +366,6 @@ const styles = StyleSheet.create({
fontSize: 12,
lineHeight: 16,
},
commandBox: {
backgroundColor: "rgba(0, 0, 0, 0.24)",
borderColor: "rgba(255, 255, 255, 0.08)",
borderRadius: 7,
borderWidth: 1,
paddingHorizontal: 9,
paddingVertical: 7,
},
commandText: {
fontFamily: Fonts.mono,
fontSize: 12,
lineHeight: 16,
},
stepAction: {
alignItems: "center",
alignSelf: "flex-start",
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/components/chat/pairing-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";

import { approvalCommand, relayStartCommand } from "./pairing-commands";

describe("pairing commands", () => {
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",
);
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/components/chat/pairing-commands.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
129 changes: 129 additions & 0 deletions apps/mobile/src/components/ui/copyable-command.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}) {
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 (
<View style={[styles.container, style]}>
<ThemedText selectable type="code" style={[styles.commandText, textStyle]}>
{command}
</ThemedText>
<Pressable
accessibilityHint="Copies this command to the clipboard"
accessibilityLabel={isCopied ? "Command copied" : copyAccessibilityLabel}
accessibilityRole="button"
hitSlop={8}
onPress={handleCopyPress}
style={({ pressed }) => [
styles.copyButton,
isCopied && styles.copyButtonCopied,
pressed && styles.pressed,
]}
>
<Icon name={isCopied ? "check" : "copy"} size={12} strokeWidth={2.2} tintColor="#F2F2F2" />
<ThemedText type="smallBold" style={styles.copyButtonText}>
{isCopied ? "Copied" : "Copy"}
</ThemedText>
</Pressable>
</View>
);
}

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,
},
});
Loading