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 SessionListView from './src/views/SessionListView'
|
||||
import ChatView from './src/views/ChatView'
|
||||
import SettingsView from './src/views/SettingsView'
|
||||
|
||||
type ViewType = 'list' | 'chat' | 'settings'
|
||||
|
||||
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 handleSessionPress = (session: Session) => {
|
||||
@ -19,11 +22,17 @@ export default function App() {
|
||||
setActiveSession(null)
|
||||
}
|
||||
|
||||
const handleSettingsPress = () => {
|
||||
setCurrentView('settings')
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<StatusBar style="light" />
|
||||
{currentView === 'list' ? (
|
||||
<SessionListView onSessionPress={handleSessionPress} />
|
||||
<SessionListView onSessionPress={handleSessionPress} onSettingsPress={handleSettingsPress} />
|
||||
) : currentView === 'settings' ? (
|
||||
<SettingsView onBack={handleBackToList} />
|
||||
) : activeSession ? (
|
||||
<ChatView session={activeSession} onBack={handleBackToList} />
|
||||
) : 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",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.10.1",
|
||||
"expo": "~54.0.32",
|
||||
@ -2731,6 +2732,18 @@
|
||||
"@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": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@ -5519,6 +5532,15 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@ -6417,6 +6439,18 @@
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-options": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-obj": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.10.1",
|
||||
"expo": "~54.0.32",
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
@ -22,6 +23,9 @@ import {
|
||||
} from '../models/types'
|
||||
import MessageInputView from './MessageInputView'
|
||||
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 {
|
||||
visible: boolean
|
||||
@ -30,28 +34,71 @@ interface Props {
|
||||
}
|
||||
|
||||
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 [selectedRepository, setSelectedRepository] = useState<Repository | undefined>()
|
||||
const [selectedRepo, setSelectedRepo] = useState<GitHubRepo | undefined>()
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('')
|
||||
const [selectedModel, setSelectedModel] = useState<AIModel>(AIModels.sonnet)
|
||||
const [showModelPicker, setShowModelPicker] = 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(() => {
|
||||
if (visible && repositories.length > 0 && !selectedRepository) {
|
||||
setSelectedRepository(repositories[0])
|
||||
if (visible && repos.length > 0 && !selectedRepo) {
|
||||
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 = () => {
|
||||
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)
|
||||
setMessageText('')
|
||||
onSessionCreate(session)
|
||||
}
|
||||
|
||||
const showNoUsername = !settings.githubUsername && !loadingRepos
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<SafeAreaView style={styles.container}>
|
||||
@ -70,46 +117,78 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
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 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<MessageInputView
|
||||
text={messageText}
|
||||
placeholder="Ask Claude to help with code..."
|
||||
onChangeText={setMessageText}
|
||||
onSend={handleCreateSession}
|
||||
/>
|
||||
{!showNoUsername && repos.length > 0 && (
|
||||
<View style={styles.inputContainer}>
|
||||
<MessageInputView
|
||||
text={messageText}
|
||||
placeholder="Ask Claude to help with code..."
|
||||
onChangeText={setMessageText}
|
||||
onSend={handleCreateSession}
|
||||
/>
|
||||
|
||||
{/* Bottom chips */}
|
||||
<View style={styles.chipsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={() => setShowModelPicker(true)}
|
||||
>
|
||||
<Ionicons name="cube-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText}>{selectedModel.displayName}</Text>
|
||||
<Ionicons name="chevron-down" size={12} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
{/* Bottom chips */}
|
||||
<View style={styles.chipsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={() => setShowModelPicker(true)}
|
||||
>
|
||||
<Ionicons name="cube-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText}>{selectedModel.displayName}</Text>
|
||||
<Ionicons name="chevron-down" size={12} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={() => setShowRepoPicker(true)}
|
||||
>
|
||||
<Ionicons name="folder-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText}>
|
||||
{selectedRepository?.name || 'Select repo'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={() => setShowRepoPicker(true)}
|
||||
>
|
||||
<Ionicons name="folder-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText} numberOfLines={1}>
|
||||
{selectedRepo?.name || 'Select repo'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{selectedRepository && (
|
||||
<View style={styles.chip}>
|
||||
<Ionicons name="git-branch-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText}>{selectedRepository.defaultBranch}</Text>
|
||||
</View>
|
||||
)}
|
||||
{selectedRepo && (
|
||||
<TouchableOpacity
|
||||
style={styles.chip}
|
||||
onPress={() => setShowBranchPicker(true)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Model Picker Modal */}
|
||||
@ -137,19 +216,46 @@ const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate })
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
title="Select Repository"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
{repos.map((repo) => (
|
||||
<PickerItem
|
||||
key={repo.id}
|
||||
title={repo.name}
|
||||
subtitle={`${repo.owner}/${repo.name}`}
|
||||
selected={selectedRepository?.id === repo.id}
|
||||
subtitle={repo.description || `Updated: ${new Date(repo.updated_at).toLocaleDateString()}`}
|
||||
selected={selectedRepo?.id === repo.id}
|
||||
onPress={() => {
|
||||
setSelectedRepository(repo)
|
||||
setSelectedRepo(repo)
|
||||
setSelectedBranch(repo.default_branch)
|
||||
setShowRepoPicker(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</Modal>
|
||||
)
|
||||
@ -184,9 +290,9 @@ const PickerItem: React.FC<{
|
||||
onPress: () => void
|
||||
}> = ({ title, subtitle, selected, onPress }) => (
|
||||
<TouchableOpacity style={styles.pickerItem} onPress={onPress}>
|
||||
<View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.pickerItemTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.pickerItemSubtitle}>{subtitle}</Text>}
|
||||
{subtitle && <Text style={styles.pickerItemSubtitle} numberOfLines={1}>{subtitle}</Text>}
|
||||
</View>
|
||||
{selected && <Ionicons name="checkmark" size={20} color={Colors.accent} />}
|
||||
</TouchableOpacity>
|
||||
@ -219,6 +325,36 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
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: {
|
||||
paddingBottom: Spacing.LG,
|
||||
},
|
||||
@ -239,10 +375,12 @@ const styles = StyleSheet.create({
|
||||
borderRadius: Radius.Full,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.chipBorder,
|
||||
maxWidth: '45%',
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 14,
|
||||
color: Colors.primaryText,
|
||||
flex: 1,
|
||||
},
|
||||
pickerContainer: {
|
||||
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
|
||||
owner: string
|
||||
defaultBranch: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
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 {
|
||||
onSessionPress: (session: Session) => void
|
||||
onSettingsPress: () => void
|
||||
}
|
||||
|
||||
const SessionListView: React.FC<Props> = ({ onSessionPress }) => {
|
||||
const SessionListView: React.FC<Props> = ({ onSessionPress, onSettingsPress }) => {
|
||||
const { sessions } = useAppState()
|
||||
const [showNewSessionSheet, setShowNewSessionSheet] = useState(false)
|
||||
|
||||
@ -27,8 +28,8 @@ const SessionListView: React.FC<Props> = ({ onSessionPress }) => {
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.menuButton}>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={Colors.primaryText} />
|
||||
<TouchableOpacity style={styles.menuButton} onPress={onSettingsPress}>
|
||||
<Ionicons name="settings-outline" size={24} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Code</Text>
|
||||
<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