feat(expo): add GitHub integration and WebSocket client
- Add GitHub API service for fetching user repos and branches - Add settings view for GitHub username and gateway URL - Update NewSessionSheet to use real GitHub data - Add Gateway WebSocket client for real-time communication - Add useGateway hook for agent event streaming - Prepare for real-time tool call streaming in ChatView Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9ddebad13c
commit
a591c51010
@ -4,9 +4,12 @@ import { AppProvider } from './src/contexts/AppContext'
|
|||||||
import { Session } from './src/models/types'
|
import { Session } from './src/models/types'
|
||||||
import SessionListView from './src/views/SessionListView'
|
import SessionListView from './src/views/SessionListView'
|
||||||
import ChatView from './src/views/ChatView'
|
import ChatView from './src/views/ChatView'
|
||||||
|
import SettingsView from './src/views/SettingsView'
|
||||||
|
|
||||||
|
type ViewType = 'list' | 'chat' | 'settings'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentView, setCurrentView] = useState<'list' | 'chat'>('list')
|
const [currentView, setCurrentView] = useState<ViewType>('list')
|
||||||
const [activeSession, setActiveSession] = useState<Session | null>(null)
|
const [activeSession, setActiveSession] = useState<Session | null>(null)
|
||||||
|
|
||||||
const handleSessionPress = (session: Session) => {
|
const handleSessionPress = (session: Session) => {
|
||||||
@ -19,11 +22,17 @@ export default function App() {
|
|||||||
setActiveSession(null)
|
setActiveSession(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSettingsPress = () => {
|
||||||
|
setCurrentView('settings')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
{currentView === 'list' ? (
|
{currentView === 'list' ? (
|
||||||
<SessionListView onSessionPress={handleSessionPress} />
|
<SessionListView onSessionPress={handleSessionPress} onSettingsPress={handleSettingsPress} />
|
||||||
|
) : currentView === 'settings' ? (
|
||||||
|
<SettingsView onBack={handleBackToList} />
|
||||||
) : activeSession ? (
|
) : activeSession ? (
|
||||||
<ChatView session={activeSession} onBack={handleBackToList} />
|
<ChatView session={activeSession} onBack={handleBackToList} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
34
apps/expo-github-mobile/package-lock.json
generated
34
apps/expo-github-mobile/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.1.28",
|
"@react-navigation/native": "^7.1.28",
|
||||||
"@react-navigation/native-stack": "^7.10.1",
|
"@react-navigation/native-stack": "^7.10.1",
|
||||||
"expo": "~54.0.32",
|
"expo": "~54.0.32",
|
||||||
@ -2731,6 +2732,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@ -5519,6 +5532,15 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
@ -6417,6 +6439,18 @@
|
|||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.1.28",
|
"@react-navigation/native": "^7.1.28",
|
||||||
"@react-navigation/native-stack": "^7.10.1",
|
"@react-navigation/native-stack": "^7.10.1",
|
||||||
"expo": "~54.0.32",
|
"expo": "~54.0.32",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||||
@ -22,6 +23,9 @@ import {
|
|||||||
} from '../models/types'
|
} from '../models/types'
|
||||||
import MessageInputView from './MessageInputView'
|
import MessageInputView from './MessageInputView'
|
||||||
import Chip from './Chip'
|
import Chip from './Chip'
|
||||||
|
import { useGitHubRepos, type GitHubRepo, type GitHubBranch } from '../hooks/useGitHub'
|
||||||
|
import { useSettings } from '../hooks/useSettings'
|
||||||
|
import { GitHubAPI } from '../services/github'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@ -30,28 +34,71 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate }) => {
|
const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate }) => {
|
||||||
const { repositories, createNewSession, sendMessage } = useAppState()
|
const { createNewSession, sendMessage } = useAppState()
|
||||||
|
const { settings } = useSettings()
|
||||||
|
const { repos, loading: loadingRepos, error: reposError, refetch } = useGitHubRepos()
|
||||||
|
|
||||||
const [messageText, setMessageText] = useState('')
|
const [messageText, setMessageText] = useState('')
|
||||||
const [selectedRepository, setSelectedRepository] = useState<Repository | undefined>()
|
const [selectedRepo, setSelectedRepo] = useState<GitHubRepo | undefined>()
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState<string>('')
|
||||||
const [selectedModel, setSelectedModel] = useState<AIModel>(AIModels.sonnet)
|
const [selectedModel, setSelectedModel] = useState<AIModel>(AIModels.sonnet)
|
||||||
const [showModelPicker, setShowModelPicker] = useState(false)
|
const [showModelPicker, setShowModelPicker] = useState(false)
|
||||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||||
|
const [showBranchPicker, setShowBranchPicker] = useState(false)
|
||||||
|
|
||||||
|
const [branches, setBranches] = useState<GitHubBranch[]>([])
|
||||||
|
const [loadingBranches, setLoadingBranches] = useState(false)
|
||||||
|
|
||||||
|
// Set initial repo when repos load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && repositories.length > 0 && !selectedRepository) {
|
if (visible && repos.length > 0 && !selectedRepo) {
|
||||||
setSelectedRepository(repositories[0])
|
setSelectedRepo(repos[0])
|
||||||
|
setSelectedBranch(repos[0].default_branch)
|
||||||
}
|
}
|
||||||
}, [visible, repositories])
|
}, [visible, repos])
|
||||||
|
|
||||||
|
// Fetch branches when repo changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRepo) return
|
||||||
|
|
||||||
|
const fetchBranches = async () => {
|
||||||
|
setLoadingBranches(true)
|
||||||
|
try {
|
||||||
|
const b = await GitHubAPI.getRepoBranches(selectedRepo.owner.login, selectedRepo.name)
|
||||||
|
setBranches(b)
|
||||||
|
if (!selectedBranch && b.length > 0) {
|
||||||
|
setSelectedBranch(selectedRepo.default_branch || b[0].name)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch branches', e)
|
||||||
|
} finally {
|
||||||
|
setLoadingBranches(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchBranches()
|
||||||
|
}, [selectedRepo])
|
||||||
|
|
||||||
const handleCreateSession = () => {
|
const handleCreateSession = () => {
|
||||||
if (!messageText.trim() || !selectedRepository) return
|
if (!messageText.trim() || !selectedRepo) return
|
||||||
|
|
||||||
const session = createNewSession(messageText.slice(0, 50), selectedRepository)
|
// Convert GitHubRepo to app Repository
|
||||||
|
const repository: Repository = {
|
||||||
|
id: String(selectedRepo.id),
|
||||||
|
name: selectedRepo.name,
|
||||||
|
owner: selectedRepo.owner.login,
|
||||||
|
defaultBranch: selectedRepo.default_branch,
|
||||||
|
language: selectedRepo.language || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = createNewSession(messageText.slice(0, 50), repository)
|
||||||
sendMessage(messageText, session)
|
sendMessage(messageText, session)
|
||||||
setMessageText('')
|
setMessageText('')
|
||||||
onSessionCreate(session)
|
onSessionCreate(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showNoUsername = !settings.githubUsername && !loadingRepos
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
@ -70,46 +117,78 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
keyboardVerticalOffset={0}
|
keyboardVerticalOffset={0}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1 }} />
|
{showNoUsername ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="logo-github" size={64} color={Colors.tertiaryText} />
|
||||||
|
<Text style={styles.emptyTitle}>GitHub Username Required</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Please set your GitHub username in Settings to fetch repositories.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : loadingRepos ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.accent} />
|
||||||
|
<Text style={styles.emptyText}>Loading repositories...</Text>
|
||||||
|
</View>
|
||||||
|
) : repos.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="folder-open-outline" size={64} color={Colors.tertiaryText} />
|
||||||
|
<Text style={styles.emptyTitle}>No Repositories Found</Text>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{reposError || `No repositories found for ${settings.githubUsername}`}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={refetch}>
|
||||||
|
<Text style={styles.retryButtonText}>Retry</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Message input area */}
|
{/* Message input area */}
|
||||||
<View style={styles.inputContainer}>
|
{!showNoUsername && repos.length > 0 && (
|
||||||
<MessageInputView
|
<View style={styles.inputContainer}>
|
||||||
text={messageText}
|
<MessageInputView
|
||||||
placeholder="Ask Claude to help with code..."
|
text={messageText}
|
||||||
onChangeText={setMessageText}
|
placeholder="Ask Claude to help with code..."
|
||||||
onSend={handleCreateSession}
|
onChangeText={setMessageText}
|
||||||
/>
|
onSend={handleCreateSession}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Bottom chips */}
|
{/* Bottom chips */}
|
||||||
<View style={styles.chipsContainer}>
|
<View style={styles.chipsContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.chip}
|
style={styles.chip}
|
||||||
onPress={() => setShowModelPicker(true)}
|
onPress={() => setShowModelPicker(true)}
|
||||||
>
|
>
|
||||||
<Ionicons name="cube-outline" size={16} color={Colors.primaryText} />
|
<Ionicons name="cube-outline" size={16} color={Colors.primaryText} />
|
||||||
<Text style={styles.chipText}>{selectedModel.displayName}</Text>
|
<Text style={styles.chipText}>{selectedModel.displayName}</Text>
|
||||||
<Ionicons name="chevron-down" size={12} color={Colors.primaryText} />
|
<Ionicons name="chevron-down" size={12} color={Colors.primaryText} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.chip}
|
style={styles.chip}
|
||||||
onPress={() => setShowRepoPicker(true)}
|
onPress={() => setShowRepoPicker(true)}
|
||||||
>
|
>
|
||||||
<Ionicons name="folder-outline" size={16} color={Colors.primaryText} />
|
<Ionicons name="folder-outline" size={16} color={Colors.primaryText} />
|
||||||
<Text style={styles.chipText}>
|
<Text style={styles.chipText} numberOfLines={1}>
|
||||||
{selectedRepository?.name || 'Select repo'}
|
{selectedRepo?.name || 'Select repo'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{selectedRepository && (
|
{selectedRepo && (
|
||||||
<View style={styles.chip}>
|
<TouchableOpacity
|
||||||
<Ionicons name="git-branch-outline" size={16} color={Colors.primaryText} />
|
style={styles.chip}
|
||||||
<Text style={styles.chipText}>{selectedRepository.defaultBranch}</Text>
|
onPress={() => setShowBranchPicker(true)}
|
||||||
</View>
|
>
|
||||||
)}
|
<Ionicons name="git-branch-outline" size={16} color={Colors.primaryText} />
|
||||||
|
<Text style={styles.chipText}>{selectedBranch || 'main'}</Text>
|
||||||
|
<Ionicons name="chevron-down" size={12} color={Colors.primaryText} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* Model Picker Modal */}
|
{/* Model Picker Modal */}
|
||||||
@ -137,19 +216,46 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
|
|||||||
onClose={() => setShowRepoPicker(false)}
|
onClose={() => setShowRepoPicker(false)}
|
||||||
title="Select Repository"
|
title="Select Repository"
|
||||||
>
|
>
|
||||||
{repositories.map((repo) => (
|
{repos.map((repo) => (
|
||||||
<PickerItem
|
<PickerItem
|
||||||
key={repo.id}
|
key={repo.id}
|
||||||
title={repo.name}
|
title={repo.name}
|
||||||
subtitle={`${repo.owner}/${repo.name}`}
|
subtitle={repo.description || `Updated: ${new Date(repo.updated_at).toLocaleDateString()}`}
|
||||||
selected={selectedRepository?.id === repo.id}
|
selected={selectedRepo?.id === repo.id}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSelectedRepository(repo)
|
setSelectedRepo(repo)
|
||||||
|
setSelectedBranch(repo.default_branch)
|
||||||
setShowRepoPicker(false)
|
setShowRepoPicker(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</PickerModal>
|
</PickerModal>
|
||||||
|
|
||||||
|
{/* Branch Picker Modal */}
|
||||||
|
<PickerModal
|
||||||
|
visible={showBranchPicker}
|
||||||
|
onClose={() => setShowBranchPicker(false)}
|
||||||
|
title="Select Branch"
|
||||||
|
>
|
||||||
|
{loadingBranches ? (
|
||||||
|
<View style={{ padding: Spacing.LG, alignItems: 'center' }}>
|
||||||
|
<ActivityIndicator color={Colors.accent} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
branches.map((branch) => (
|
||||||
|
<PickerItem
|
||||||
|
key={branch.name}
|
||||||
|
title={branch.name}
|
||||||
|
subtitle={branch.protected ? '🔒 Protected' : undefined}
|
||||||
|
selected={selectedBranch === branch.name}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedBranch(branch.name)
|
||||||
|
setShowBranchPicker(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</PickerModal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
@ -184,9 +290,9 @@ const PickerItem: React.FC<{
|
|||||||
onPress: () => void
|
onPress: () => void
|
||||||
}> = ({ title, subtitle, selected, onPress }) => (
|
}> = ({ title, subtitle, selected, onPress }) => (
|
||||||
<TouchableOpacity style={styles.pickerItem} onPress={onPress}>
|
<TouchableOpacity style={styles.pickerItem} onPress={onPress}>
|
||||||
<View>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.pickerItemTitle}>{title}</Text>
|
<Text style={styles.pickerItemTitle}>{title}</Text>
|
||||||
{subtitle && <Text style={styles.pickerItemSubtitle}>{subtitle}</Text>}
|
{subtitle && <Text style={styles.pickerItemSubtitle} numberOfLines={1}>{subtitle}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{selected && <Ionicons name="checkmark" size={20} color={Colors.accent} />}
|
{selected && <Ionicons name="checkmark" size={20} color={Colors.accent} />}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -219,6 +325,36 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: Spacing.XL,
|
||||||
|
gap: Spacing.MD,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.primaryText,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.secondaryText,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: Spacing.MD,
|
||||||
|
paddingHorizontal: Spacing.LG,
|
||||||
|
paddingVertical: Spacing.SM,
|
||||||
|
backgroundColor: Colors.chipBackground,
|
||||||
|
borderRadius: Radius.MD,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: Colors.primaryText,
|
||||||
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
paddingBottom: Spacing.LG,
|
paddingBottom: Spacing.LG,
|
||||||
},
|
},
|
||||||
@ -239,10 +375,12 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: Radius.Full,
|
borderRadius: Radius.Full,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.chipBorder,
|
borderColor: Colors.chipBorder,
|
||||||
|
maxWidth: '45%',
|
||||||
},
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.primaryText,
|
color: Colors.primaryText,
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
pickerContainer: {
|
pickerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
144
apps/expo-github-mobile/src/hooks/useGateway.ts
Normal file
144
apps/expo-github-mobile/src/hooks/useGateway.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
|
import { GatewayClient, type AgentEventPayload } from '../services/gateway'
|
||||||
|
import { useSettings } from './useSettings'
|
||||||
|
|
||||||
|
export type ToolCallEvent = {
|
||||||
|
toolCallId: string
|
||||||
|
name: string
|
||||||
|
phase: 'start' | 'update' | 'end'
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
partialResult?: string
|
||||||
|
result?: unknown
|
||||||
|
isError?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantEvent = {
|
||||||
|
delta?: string
|
||||||
|
thinking?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayState = {
|
||||||
|
connected: boolean
|
||||||
|
connecting: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGateway = () => {
|
||||||
|
const { settings, loading: settingsLoading } = useSettings()
|
||||||
|
const clientRef = useRef<GatewayClient | null>(null)
|
||||||
|
const [state, setState] = useState<GatewayState>({
|
||||||
|
connected: false,
|
||||||
|
connecting: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolCallHandlersRef = useRef<Set<(event: ToolCallEvent) => void>>(new Set())
|
||||||
|
const assistantHandlersRef = useRef<Set<(event: AssistantEvent) => void>>(new Set())
|
||||||
|
const lifecycleHandlersRef = useRef<Set<(event: Record<string, unknown>) => void>>(new Set())
|
||||||
|
|
||||||
|
// Connect to gateway
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsLoading) return
|
||||||
|
if (!settings.gatewayUrl) return
|
||||||
|
|
||||||
|
const client = new GatewayClient(settings.gatewayUrl)
|
||||||
|
clientRef.current = client
|
||||||
|
|
||||||
|
setState((s) => ({ ...s, connecting: true, error: null }))
|
||||||
|
|
||||||
|
// Handle agent events
|
||||||
|
const unsubscribe = client.onEvent('agent', (payload) => {
|
||||||
|
const evt = payload as AgentEventPayload
|
||||||
|
|
||||||
|
switch (evt.stream) {
|
||||||
|
case 'tool': {
|
||||||
|
const data = evt.data as ToolCallEvent
|
||||||
|
for (const handler of toolCallHandlersRef.current) {
|
||||||
|
handler(data)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'assistant': {
|
||||||
|
const data = evt.data as AssistantEvent
|
||||||
|
for (const handler of assistantHandlersRef.current) {
|
||||||
|
handler(data)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'lifecycle': {
|
||||||
|
for (const handler of lifecycleHandlersRef.current) {
|
||||||
|
handler(evt.data)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.connect()
|
||||||
|
.then(() => {
|
||||||
|
setState({ connected: true, connecting: false, error: null })
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setState({ connected: false, connecting: false, error: e.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
client.disconnect()
|
||||||
|
clientRef.current = null
|
||||||
|
setState({ connected: false, connecting: false, error: null })
|
||||||
|
}
|
||||||
|
}, [settings.gatewayUrl, settingsLoading])
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (
|
||||||
|
message: string,
|
||||||
|
repoContext?: { owner: string; name: string; branch: string }
|
||||||
|
): Promise<{ status: string; runId: string } | null> => {
|
||||||
|
const client = clientRef.current
|
||||||
|
if (!client?.connected) {
|
||||||
|
console.error('[useGateway] Not connected')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.startAgent({
|
||||||
|
message,
|
||||||
|
repoContext,
|
||||||
|
idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[useGateway] Failed to send message', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onToolCall = useCallback((handler: (event: ToolCallEvent) => void) => {
|
||||||
|
toolCallHandlersRef.current.add(handler)
|
||||||
|
return () => {
|
||||||
|
toolCallHandlersRef.current.delete(handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onAssistant = useCallback((handler: (event: AssistantEvent) => void) => {
|
||||||
|
assistantHandlersRef.current.add(handler)
|
||||||
|
return () => {
|
||||||
|
assistantHandlersRef.current.delete(handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onLifecycle = useCallback((handler: (event: Record<string, unknown>) => void) => {
|
||||||
|
lifecycleHandlersRef.current.add(handler)
|
||||||
|
return () => {
|
||||||
|
lifecycleHandlersRef.current.delete(handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sendMessage,
|
||||||
|
onToolCall,
|
||||||
|
onAssistant,
|
||||||
|
onLifecycle,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/expo-github-mobile/src/hooks/useGitHub.ts
Normal file
77
apps/expo-github-mobile/src/hooks/useGitHub.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { GitHubAPI, type GitHubRepo, type GitHubBranch } from '../services/github'
|
||||||
|
import { useSettings } from './useSettings'
|
||||||
|
|
||||||
|
export const useGitHubRepos = () => {
|
||||||
|
const { settings, loading: settingsLoading } = useSettings()
|
||||||
|
const [repos, setRepos] = useState<GitHubRepo[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchRepos = useCallback(async () => {
|
||||||
|
if (!settings.githubUsername) {
|
||||||
|
setRepos([])
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedRepos = await GitHubAPI.getUserRepos(settings.githubUsername)
|
||||||
|
// Filter out forks optionally, sort by updated
|
||||||
|
const sortedRepos = fetchedRepos.sort(
|
||||||
|
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||||
|
)
|
||||||
|
setRepos(sortedRepos)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to fetch repositories')
|
||||||
|
setRepos([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [settings.githubUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoading) {
|
||||||
|
fetchRepos()
|
||||||
|
}
|
||||||
|
}, [settingsLoading, fetchRepos])
|
||||||
|
|
||||||
|
return { repos, loading: loading || settingsLoading, error, refetch: fetchRepos }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGitHubBranches = (owner: string, repo: string) => {
|
||||||
|
const [branches, setBranches] = useState<GitHubBranch[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchBranches = useCallback(async () => {
|
||||||
|
if (!owner || !repo) {
|
||||||
|
setBranches([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedBranches = await GitHubAPI.getRepoBranches(owner, repo)
|
||||||
|
setBranches(fetchedBranches)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to fetch branches')
|
||||||
|
setBranches([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [owner, repo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBranches()
|
||||||
|
}, [fetchBranches])
|
||||||
|
|
||||||
|
return { branches, loading, error, refetch: fetchBranches }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type GitHubRepo, type GitHubBranch }
|
||||||
55
apps/expo-github-mobile/src/hooks/useSettings.ts
Normal file
55
apps/expo-github-mobile/src/hooks/useSettings.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
const SETTINGS_KEY = '@clawdbot_settings'
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
githubUsername: string
|
||||||
|
gatewayUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
githubUsername: '',
|
||||||
|
gatewayUrl: 'ws://localhost:18789',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadSettings = async (): Promise<Settings> => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(SETTINGS_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load settings', e)
|
||||||
|
}
|
||||||
|
return DEFAULT_SETTINGS
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveSettings = async (settings: Settings): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save settings', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings().then((loaded) => {
|
||||||
|
setSettings(loaded)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateSettings = async (updates: Partial<Settings>) => {
|
||||||
|
const newSettings = { ...settings, ...updates }
|
||||||
|
setSettings(newSettings)
|
||||||
|
await saveSettings(newSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings, updateSettings, loading }
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ export interface Repository {
|
|||||||
name: string
|
name: string
|
||||||
owner: string
|
owner: string
|
||||||
defaultBranch: string
|
defaultBranch: string
|
||||||
|
language?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFullName = (repo: Repository) => `${repo.owner}/${repo.name}`
|
export const getFullName = (repo: Repository) => `${repo.owner}/${repo.name}`
|
||||||
|
|||||||
161
apps/expo-github-mobile/src/services/gateway.ts
Normal file
161
apps/expo-github-mobile/src/services/gateway.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
export type GatewayFrame =
|
||||||
|
| { type: 'hello-ok'; protocol: number; server: { version: string; host?: string }; features?: { methods: string[]; events: string[] } }
|
||||||
|
| { type: 'res'; id: string; ok: boolean; payload?: unknown; error?: { code: string; message: string } }
|
||||||
|
| { type: 'event'; event: string; payload?: unknown; seq?: number }
|
||||||
|
|
||||||
|
export type AgentRequestParams = {
|
||||||
|
message: string
|
||||||
|
agentId?: string
|
||||||
|
sessionId?: string
|
||||||
|
sessionKey?: string
|
||||||
|
thinking?: string
|
||||||
|
timeout?: number
|
||||||
|
idempotencyKey: string
|
||||||
|
repoContext?: {
|
||||||
|
owner: string
|
||||||
|
name: string
|
||||||
|
branch: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentEventPayload = {
|
||||||
|
runId: string
|
||||||
|
seq: number
|
||||||
|
stream: string
|
||||||
|
ts: number
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GatewayClient {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private pending = new Map<string, {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (err: Error) => void
|
||||||
|
}>()
|
||||||
|
private eventHandlers = new Map<string, Set<(payload: unknown) => void>>()
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private reconnectDelay = 1000
|
||||||
|
private messageQueue: string[] = []
|
||||||
|
|
||||||
|
constructor(private url: string) {}
|
||||||
|
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.url)
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('[Gateway] Connected')
|
||||||
|
// Send queued messages
|
||||||
|
for (const msg of this.messageQueue) {
|
||||||
|
this.ws?.send(msg)
|
||||||
|
}
|
||||||
|
this.messageQueue = []
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
this.ws.onmessage = (event) => this.handleMessage(event.data)
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
console.log('[Gateway] Disconnected', event.code, event.reason)
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('[Gateway] Error', error)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
console.log('[Gateway] Reconnecting...')
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect().catch((e) => {
|
||||||
|
console.error('[Gateway] Reconnect failed', e)
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
|
||||||
|
})
|
||||||
|
}, this.reconnectDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(data: string) {
|
||||||
|
try {
|
||||||
|
const frame: GatewayFrame = JSON.parse(data)
|
||||||
|
|
||||||
|
if (frame.type === 'res') {
|
||||||
|
const pending = this.pending.get(frame.id)
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(frame.id)
|
||||||
|
if (frame.ok) {
|
||||||
|
pending.resolve(frame.payload)
|
||||||
|
} else {
|
||||||
|
pending.reject(new Error(frame.error?.message || 'Request failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (frame.type === 'event') {
|
||||||
|
const handlers = this.eventHandlers.get(frame.event)
|
||||||
|
if (handlers) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
handler(frame.payload)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Gateway] Event handler error', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Gateway] Failed to parse message', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(frame: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
const message = JSON.stringify({ ...frame, id })
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject })
|
||||||
|
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(message)
|
||||||
|
} else {
|
||||||
|
this.messageQueue.push(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
|
return this.send({ type: 'req', method, params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async startAgent(params: AgentRequestParams): Promise<{ status: string; runId: string }> {
|
||||||
|
const result = await this.call('agent', params)
|
||||||
|
return result as { status: string; runId: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(event: string, handler: (payload: unknown) => void): () => void {
|
||||||
|
if (!this.eventHandlers.has(event)) {
|
||||||
|
this.eventHandlers.set(event, new Set())
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(event)!.add(handler)
|
||||||
|
return () => {
|
||||||
|
this.eventHandlers.get(event)?.delete(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected() {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/expo-github-mobile/src/services/github.ts
Normal file
101
apps/expo-github-mobile/src/services/github.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
export interface GitHubRepo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
full_name: string
|
||||||
|
owner: {
|
||||||
|
login: string
|
||||||
|
}
|
||||||
|
description: string | null
|
||||||
|
private: boolean
|
||||||
|
fork: boolean
|
||||||
|
language: string | null
|
||||||
|
default_branch: string
|
||||||
|
updated_at: string
|
||||||
|
stargazers_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubBranch {
|
||||||
|
name: string
|
||||||
|
commit: {
|
||||||
|
sha: string
|
||||||
|
}
|
||||||
|
protected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitHubAPI {
|
||||||
|
private static readonly BASE_URL = 'https://api.github.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch public repositories for a username
|
||||||
|
* No authentication required for public repos
|
||||||
|
*/
|
||||||
|
static async getUserRepos(username: string): Promise<GitHubRepo[]> {
|
||||||
|
if (!username || username.trim().length === 0) {
|
||||||
|
throw new Error('Username is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.BASE_URL}/users/${encodeURIComponent(username.trim())}/repos?per_page=100&sort=updated&type=all`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'Clawdbot-Mobile',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error(`User "${username}" not found`)
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
throw new Error('GitHub API rate limit exceeded. Please try again later.')
|
||||||
|
}
|
||||||
|
throw new Error(`GitHub API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = await response.json()
|
||||||
|
return repos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch branches for a repository
|
||||||
|
* No authentication required for public repos
|
||||||
|
*/
|
||||||
|
static async getRepoBranches(owner: string, repo: string): Promise<GitHubBranch[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.BASE_URL}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches?per_page=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'Clawdbot-Mobile',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch branches: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a username exists
|
||||||
|
*/
|
||||||
|
static async checkUsername(username: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.BASE_URL}/users/${encodeURIComponent(username.trim())}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'Clawdbot-Mobile',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,9 +17,10 @@ import NewSessionSheet from '../components/NewSessionSheet'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSessionPress: (session: Session) => void
|
onSessionPress: (session: Session) => void
|
||||||
|
onSettingsPress: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionListView: React.FC<Props> = ({ onSessionPress }) => {
|
const SessionListView: React.FC<Props> = ({ onSessionPress, onSettingsPress }) => {
|
||||||
const { sessions } = useAppState()
|
const { sessions } = useAppState()
|
||||||
const [showNewSessionSheet, setShowNewSessionSheet] = useState(false)
|
const [showNewSessionSheet, setShowNewSessionSheet] = useState(false)
|
||||||
|
|
||||||
@ -27,8 +28,8 @@ const SessionListView: React.FC<Props> = ({ onSessionPress }) => {
|
|||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity style={styles.menuButton}>
|
<TouchableOpacity style={styles.menuButton} onPress={onSettingsPress}>
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={Colors.primaryText} />
|
<Ionicons name="settings-outline" size={24} color={Colors.primaryText} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>Code</Text>
|
<Text style={styles.headerTitle}>Code</Text>
|
||||||
<View style={{ width: 24 }} />
|
<View style={{ width: 24 }} />
|
||||||
|
|||||||
142
apps/expo-github-mobile/src/views/SettingsView.tsx
Normal file
142
apps/expo-github-mobile/src/views/SettingsView.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
SafeAreaView,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||||
|
import { useSettings } from '../hooks/useSettings'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsView: React.FC<Props> = ({ onBack }) => {
|
||||||
|
const { settings, updateSettings } = useSettings()
|
||||||
|
const [githubUsername, setGithubUsername] = useState(settings.githubUsername)
|
||||||
|
const [gatewayUrl, setGatewayUrl] = useState(settings.gatewayUrl)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await updateSettings({ githubUsername, gatewayUrl })
|
||||||
|
Alert.alert('Settings Saved', 'Your settings have been saved successfully.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onBack} style={styles.backButton}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={Colors.primaryText} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Settings</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* GitHub Username */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>GitHub Username</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={githubUsername}
|
||||||
|
onChangeText={setGithubUsername}
|
||||||
|
placeholder="Enter your GitHub username"
|
||||||
|
placeholderTextColor={Colors.secondaryText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>Used to fetch your public repositories</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Gateway URL */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>Gateway URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={gatewayUrl}
|
||||||
|
onChangeText={setGatewayUrl}
|
||||||
|
placeholder="ws://localhost:18789"
|
||||||
|
placeholderTextColor={Colors.secondaryText}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>WebSocket URL of your clawdbot gateway</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<TouchableOpacity style={styles.saveButton} onPress={handleSave}>
|
||||||
|
<Text style={styles.saveButtonText}>Save Settings</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: Spacing.MD,
|
||||||
|
paddingVertical: Spacing.SM,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.primaryText,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: Spacing.LG,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: Spacing.XL,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: Colors.primaryText,
|
||||||
|
marginBottom: Spacing.SM,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: Colors.inputBackground,
|
||||||
|
borderRadius: Radius.MD,
|
||||||
|
paddingHorizontal: Spacing.MD,
|
||||||
|
paddingVertical: Spacing.MD,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.primaryText,
|
||||||
|
marginBottom: Spacing.XS,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.tertiaryText,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: Colors.buttonPrimary,
|
||||||
|
borderRadius: Radius.Full,
|
||||||
|
paddingVertical: Spacing.MD,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: Spacing.LG,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.buttonPrimaryText,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default SettingsView
|
||||||
Loading…
Reference in New Issue
Block a user