diff --git a/app.json b/app.json
index 2b489cd..951ef0a 100644
--- a/app.json
+++ b/app.json
@@ -18,6 +18,7 @@
"bundleIdentifier": "com.openpad.app"
},
"android": {
+ "package": "com.openpad.app",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 62de6f8..37238d3 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,7 +1,19 @@
-import { DynamicColorIOS } from 'react-native';
-import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
+import { Platform } from 'react-native';
+import { NativeTabs, Icon, Label, VectorIcon } from 'expo-router/unstable-native-tabs';
+import { useTheme } from '../../src/hooks/useTheme';
+import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
+ const { isDark } = useTheme();
+
+ const labelColor = Platform.OS === 'ios'
+ ? { color: { dark: 'white', light: 'black' } as any }
+ : isDark ? 'white' : 'black';
+
+ const tintColor = Platform.OS === 'ios'
+ ? { color: { dark: '#22d3ee', light: '#0891b2' } as any }
+ : isDark ? '#22d3ee' : '#0891b2';
+
return (
-
+ }
+ />
-
+
-
+ }
+ />
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 95fdec4..3b44ada 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native';
@@ -9,29 +9,19 @@ function AuthRedirect({ children }: { children: React.ReactNode }) {
const { connected, connecting, connect } = useOpenCode();
const segments = useSegments();
const router = useRouter();
- const [autoConnectAttempted, setAutoConnectAttempted] = useState(false);
-
- // Auto-connect on mount
- useEffect(() => {
- if (!autoConnectAttempted) {
- setAutoConnectAttempted(true);
- connect();
- }
- }, [autoConnectAttempted, connect]);
useEffect(() => {
// Wait for auto-connect to finish
- if (connecting) return;
+ if (connecting) {
+ return;
+ }
const inAuthGroup = segments[0] === '(tabs)';
const inChatScreen = segments[0] === 'chat';
- const onConnectScreen = segments[0] === 'connect';
if (!connected && (inAuthGroup || inChatScreen)) {
- // Redirect to connect if not authenticated
router.replace('/connect');
} else if (connected && !inAuthGroup && !inChatScreen && segments.length > 0) {
- // Redirect to tabs if authenticated and not already in tabs or chat
router.replace('/(tabs)/sessions');
}
}, [connected, connecting, segments, router]);
diff --git a/app/index.tsx b/app/index.tsx
index bcaeb5e..aedd532 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -2,12 +2,7 @@ import { Redirect } from 'expo-router';
import { useOpenCode } from '../src/providers/OpenCodeProvider';
export default function Index() {
- const { connected, connecting } = useOpenCode();
-
- // While connecting, show nothing (the layout handles the redirect)
- if (connecting) {
- return null;
- }
+ const { connected } = useOpenCode();
if (connected) {
return ;
diff --git a/package-lock.json b/package-lock.json
index 5442014..6b995fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@opencode-ai/sdk": "^1.0.167",
+ "@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.30",
"expo-blur": "^15.0.8",
"expo-glass-effect": "^0.1.8",
@@ -2931,6 +2932,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -6263,6 +6276,15 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -7209,6 +7231,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index 0d1d3c0..c65a8b3 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@opencode-ai/sdk": "^1.0.167",
+ "@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.30",
"expo-blur": "^15.0.8",
"expo-glass-effect": "^0.1.8",
diff --git a/src/providers/OpenCodeProvider.tsx b/src/providers/OpenCodeProvider.tsx
index 165a54f..ac0a978 100644
--- a/src/providers/OpenCodeProvider.tsx
+++ b/src/providers/OpenCodeProvider.tsx
@@ -1,6 +1,9 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { createOpencodeClient } from '@opencode-ai/sdk/client';
+const SERVER_URL_KEY = 'opencode_server_url';
+
export type OpenCodeClient = ReturnType;
export interface Session {
@@ -124,7 +127,14 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState(null);
- const [serverUrl, setServerUrl] = useState(defaultServerUrl);
+ const [serverUrl, setServerUrlState] = useState(defaultServerUrl);
+
+ const setServerUrl = useCallback((url: string) => {
+ setServerUrlState(url);
+ AsyncStorage.setItem(SERVER_URL_KEY, url).catch(err => {
+ console.error('Failed to save server URL to storage:', err);
+ });
+ }, []);
const clientRef = useRef(null);
@@ -176,9 +186,13 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
return '';
};
+ const connectionIdRef = useRef(0);
+
// Connect to server
const connect = useCallback(async (url?: string) => {
const targetUrl = url || serverUrl;
+ const connectionId = ++connectionIdRef.current;
+
setConnecting(true);
setError(null);
@@ -187,15 +201,28 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
baseUrl: targetUrl,
});
- // Test connection
- await client.session.list();
+ // Test connection with a timeout
+ const listPromise = client.session.list();
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Connection timed out')), 5000)
+ );
+
+ await Promise.race([listPromise, timeoutPromise]);
+ if (connectionId !== connectionIdRef.current) {
+ return false;
+ }
+
clientRef.current = client;
setServerUrl(targetUrl);
setConnected(true);
setConnecting(false);
return true;
} catch (err) {
+ if (connectionId !== connectionIdRef.current) {
+ return false;
+ }
+
setError((err as Error).message);
setConnected(false);
setConnecting(false);
@@ -203,6 +230,26 @@ export function OpenCodeProvider({ children, defaultServerUrl = 'http://10.0.10.
}
}, [serverUrl]);
+ // Load saved URL and auto-connect on mount
+ useEffect(() => {
+ const init = async () => {
+ try {
+ const savedUrl = await AsyncStorage.getItem(SERVER_URL_KEY);
+ if (savedUrl) {
+ setServerUrlState(savedUrl);
+ // Silently try to connect
+ connect(savedUrl);
+ } else {
+ connect(serverUrl);
+ }
+ } catch (err) {
+ // Fallback to default
+ connect(serverUrl);
+ }
+ };
+ init();
+ }, []); // Run once on mount
+
// Disconnect
const disconnect = useCallback(() => {
// Stop any SSE subscription
diff --git a/src/screens/SessionsScreen.tsx b/src/screens/SessionsScreen.tsx
index 44d05cf..e677fe9 100644
--- a/src/screens/SessionsScreen.tsx
+++ b/src/screens/SessionsScreen.tsx
@@ -6,6 +6,7 @@ import {
TouchableOpacity,
RefreshControl,
StyleSheet,
+ Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../hooks/useTheme';
@@ -184,8 +185,8 @@ export function SessionsScreen({
);
};
- // Extra padding for the floating liquid glass tab bar on iPad
- const topPadding = insets.top + 60;
+ // Extra padding for the floating liquid glass tab bar on iOS/iPad
+ const topPadding = Platform.OS === 'ios' ? insets.top + 60 : insets.top + spacing.lg;
return (
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 9cfa41d..740d0d5 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -5,6 +5,7 @@ import {
TouchableOpacity,
StyleSheet,
ScrollView,
+ Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../hooks/useTheme';
@@ -23,8 +24,8 @@ export function SettingsScreen({
const { theme, colors: c } = useTheme();
const insets = useSafeAreaInsets();
- // Extra padding for the floating liquid glass tab bar on iPad
- const topPadding = insets.top + 60;
+ // Extra padding for the floating liquid glass tab bar on iOS/iPad
+ const topPadding = Platform.OS === 'ios' ? insets.top + 60 : insets.top + spacing.lg;
return (