Skip to content
Open
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
1 change: 1 addition & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"bundleIdentifier": "com.openpad.app"
},
"android": {
"package": "com.openpad.app",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
Expand Down
38 changes: 25 additions & 13 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
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 (
<NativeTabs
// iOS 26 Liquid Glass features
minimizeBehavior="onScrollDown"
disableTransparentOnScrollEdge // For FlatList compatibility
// Styling for liquid glass color adaptation
labelStyle={{
color: DynamicColorIOS({
dark: 'white',
light: 'black',
}),
color: labelColor as any,
}}
tintColor={DynamicColorIOS({
dark: '#22d3ee',
light: '#0891b2',
})}
tintColor={tintColor as any}
>
<NativeTabs.Trigger name="sessions">
<Icon sf={{ default: 'bubble.left', selected: 'bubble.left.fill' }} />
<Icon
sf={{ default: 'bubble.left', selected: 'bubble.left.fill' }}
src={<VectorIcon family={Ionicons} name="chatbubble-outline" />}
/>
<Label>Sessions</Label>
</NativeTabs.Trigger>

<NativeTabs.Trigger name="settings">
<Icon sf={{ default: 'gear', selected: 'gear' }} />
<Icon
sf={{ default: 'gear', selected: 'gear' }}
src={<VectorIcon family={Ionicons} name="settings-outline" />}
/>
<Label>Settings</Label>
</NativeTabs.Trigger>
</NativeTabs>
Expand Down
18 changes: 4 additions & 14 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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]);
Expand Down
7 changes: 1 addition & 6 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Redirect href="/(tabs)/sessions" />;
Expand Down
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 50 additions & 3 deletions src/providers/OpenCodeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createOpencodeClient>;

export interface Session {
Expand Down Expand Up @@ -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<string | null>(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<OpenCodeClient | null>(null);

Expand Down Expand Up @@ -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);

Expand All @@ -187,22 +201,55 @@ 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);
return false;
}
}, [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
Expand Down
5 changes: 3 additions & 2 deletions src/screens/SessionsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<View style={theme.container}>
Expand Down
5 changes: 3 additions & 2 deletions src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Comment on lines +27 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Extract duplicated padding logic to a shared constant.

This exact padding calculation is duplicated in SessionsScreen.tsx (lines 188-189). The magic number 60 lacks explanation and should be extracted to a named constant in the theme or a utility module.

🔎 Proposed refactor to eliminate duplication

Add to src/theme/index.ts or create a new src/utils/layout.ts:

import { Platform } from 'react-native';
import { EdgeInsets } from 'react-native-safe-area-context';
import { spacing } from '../theme';

// Height of the floating tab bar on iOS
const IOS_TAB_BAR_OFFSET = 60;

export function getHeaderTopPadding(insets: EdgeInsets): number {
  return Platform.OS === 'ios' 
    ? insets.top + IOS_TAB_BAR_OFFSET 
    : insets.top + spacing.lg;
}

Then in both SettingsScreen.tsx and SessionsScreen.tsx:

-  // Extra padding for the floating liquid glass tab bar on iOS/iPad
-  const topPadding = Platform.OS === 'ios' ? insets.top + 60 : insets.top + spacing.lg;
+  const topPadding = getHeaderTopPadding(insets);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/screens/SettingsScreen.tsx around lines 27-28, the topPadding calculation
duplicates logic found in SessionsScreen and uses the magic number 60; extract
this into a shared constant/function (e.g., add IOS_TAB_BAR_OFFSET = 60 and a
getHeaderTopPadding(insets) util in src/theme/index.ts or src/utils/layout.ts)
and replace the inline expression with a call to that helper in both
SettingsScreen.tsx and SessionsScreen.tsx so the platform check, iOS offset, and
spacing.lg usage are centralized and documented.


return (
<View style={theme.container}>
Expand Down