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:
semihpolat 2026-01-23 14:16:02 -08:00
parent 9ddebad13c
commit a591c51010
12 changed files with 916 additions and 52 deletions

View File

@ -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}

View File

@ -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",

View File

@ -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",

View File

@ -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,

View 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,
}
}

View 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 }

View 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 }
}

View File

@ -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}`

View 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
}
}

View 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
}
}
}

View File

@ -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 }} />

View 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