From a591c51010002e0268bb62c486099ec453cc254b Mon Sep 17 00:00:00 2001 From: semihpolat <62581780+semihpolat@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:16:02 -0800 Subject: [PATCH] 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 --- apps/expo-github-mobile/App.tsx | 13 +- apps/expo-github-mobile/package-lock.json | 34 +++ apps/expo-github-mobile/package.json | 1 + .../src/components/NewSessionSheet.tsx | 232 ++++++++++++++---- .../src/hooks/useGateway.ts | 144 +++++++++++ .../expo-github-mobile/src/hooks/useGitHub.ts | 77 ++++++ .../src/hooks/useSettings.ts | 55 +++++ apps/expo-github-mobile/src/models/types.ts | 1 + .../src/services/gateway.ts | 161 ++++++++++++ .../expo-github-mobile/src/services/github.ts | 101 ++++++++ .../src/views/SessionListView.tsx | 7 +- .../src/views/SettingsView.tsx | 142 +++++++++++ 12 files changed, 916 insertions(+), 52 deletions(-) create mode 100644 apps/expo-github-mobile/src/hooks/useGateway.ts create mode 100644 apps/expo-github-mobile/src/hooks/useGitHub.ts create mode 100644 apps/expo-github-mobile/src/hooks/useSettings.ts create mode 100644 apps/expo-github-mobile/src/services/gateway.ts create mode 100644 apps/expo-github-mobile/src/services/github.ts create mode 100644 apps/expo-github-mobile/src/views/SettingsView.tsx diff --git a/apps/expo-github-mobile/App.tsx b/apps/expo-github-mobile/App.tsx index ec61efebc..da1151bde 100644 --- a/apps/expo-github-mobile/App.tsx +++ b/apps/expo-github-mobile/App.tsx @@ -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('list') const [activeSession, setActiveSession] = useState(null) const handleSessionPress = (session: Session) => { @@ -19,11 +22,17 @@ export default function App() { setActiveSession(null) } + const handleSettingsPress = () => { + setCurrentView('settings') + } + return ( {currentView === 'list' ? ( - + + ) : currentView === 'settings' ? ( + ) : activeSession ? ( ) : null} diff --git a/apps/expo-github-mobile/package-lock.json b/apps/expo-github-mobile/package-lock.json index 88e769aab..c4e982bbc 100644 --- a/apps/expo-github-mobile/package-lock.json +++ b/apps/expo-github-mobile/package-lock.json @@ -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", diff --git a/apps/expo-github-mobile/package.json b/apps/expo-github-mobile/package.json index 2a8f7e7d6..b20bcce8e 100644 --- a/apps/expo-github-mobile/package.json +++ b/apps/expo-github-mobile/package.json @@ -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", diff --git a/apps/expo-github-mobile/src/components/NewSessionSheet.tsx b/apps/expo-github-mobile/src/components/NewSessionSheet.tsx index b11375903..d30d1b6af 100644 --- a/apps/expo-github-mobile/src/components/NewSessionSheet.tsx +++ b/apps/expo-github-mobile/src/components/NewSessionSheet.tsx @@ -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 = ({ 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() + const [selectedRepo, setSelectedRepo] = useState() + const [selectedBranch, setSelectedBranch] = useState('') const [selectedModel, setSelectedModel] = useState(AIModels.sonnet) const [showModelPicker, setShowModelPicker] = useState(false) const [showRepoPicker, setShowRepoPicker] = useState(false) + const [showBranchPicker, setShowBranchPicker] = useState(false) + const [branches, setBranches] = useState([]) + 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 ( @@ -70,46 +117,78 @@ const NewSessionSheet: React.FC = ({ visible, onClose, onSessionCreate }) behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={0} > - + {showNoUsername ? ( + + + GitHub Username Required + + Please set your GitHub username in Settings to fetch repositories. + + + ) : loadingRepos ? ( + + + Loading repositories... + + ) : repos.length === 0 ? ( + + + No Repositories Found + + {reposError || `No repositories found for ${settings.githubUsername}`} + + + Retry + + + ) : ( + + )} {/* Message input area */} - - + {!showNoUsername && repos.length > 0 && ( + + - {/* Bottom chips */} - - setShowModelPicker(true)} - > - - {selectedModel.displayName} - - + {/* Bottom chips */} + + setShowModelPicker(true)} + > + + {selectedModel.displayName} + + - setShowRepoPicker(true)} - > - - - {selectedRepository?.name || 'Select repo'} - - + setShowRepoPicker(true)} + > + + + {selectedRepo?.name || 'Select repo'} + + - {selectedRepository && ( - - - {selectedRepository.defaultBranch} - - )} + {selectedRepo && ( + setShowBranchPicker(true)} + > + + {selectedBranch || 'main'} + + + )} + - + )} {/* Model Picker Modal */} @@ -137,19 +216,46 @@ const NewSessionSheet: React.FC = ({ visible, onClose, onSessionCreate }) onClose={() => setShowRepoPicker(false)} title="Select Repository" > - {repositories.map((repo) => ( + {repos.map((repo) => ( { - setSelectedRepository(repo) + setSelectedRepo(repo) + setSelectedBranch(repo.default_branch) setShowRepoPicker(false) }} /> ))} + + {/* Branch Picker Modal */} + setShowBranchPicker(false)} + title="Select Branch" + > + {loadingBranches ? ( + + + + ) : ( + branches.map((branch) => ( + { + setSelectedBranch(branch.name) + setShowBranchPicker(false) + }} + /> + )) + )} + ) @@ -184,9 +290,9 @@ const PickerItem: React.FC<{ onPress: () => void }> = ({ title, subtitle, selected, onPress }) => ( - + {title} - {subtitle && {subtitle}} + {subtitle && {subtitle}} {selected && } @@ -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, diff --git a/apps/expo-github-mobile/src/hooks/useGateway.ts b/apps/expo-github-mobile/src/hooks/useGateway.ts new file mode 100644 index 000000000..fbdaf75d8 --- /dev/null +++ b/apps/expo-github-mobile/src/hooks/useGateway.ts @@ -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 + 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(null) + const [state, setState] = useState({ + connected: false, + connecting: false, + error: null, + }) + + const toolCallHandlersRef = useRef void>>(new Set()) + const assistantHandlersRef = useRef void>>(new Set()) + const lifecycleHandlersRef = useRef) => 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) => void) => { + lifecycleHandlersRef.current.add(handler) + return () => { + lifecycleHandlersRef.current.delete(handler) + } + }, []) + + return { + ...state, + sendMessage, + onToolCall, + onAssistant, + onLifecycle, + } +} diff --git a/apps/expo-github-mobile/src/hooks/useGitHub.ts b/apps/expo-github-mobile/src/hooks/useGitHub.ts new file mode 100644 index 000000000..a2dcbabda --- /dev/null +++ b/apps/expo-github-mobile/src/hooks/useGitHub.ts @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 } diff --git a/apps/expo-github-mobile/src/hooks/useSettings.ts b/apps/expo-github-mobile/src/hooks/useSettings.ts new file mode 100644 index 000000000..a9d87bbb8 --- /dev/null +++ b/apps/expo-github-mobile/src/hooks/useSettings.ts @@ -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 => { + 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 => { + 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(DEFAULT_SETTINGS) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadSettings().then((loaded) => { + setSettings(loaded) + setLoading(false) + }) + }, []) + + const updateSettings = async (updates: Partial) => { + const newSettings = { ...settings, ...updates } + setSettings(newSettings) + await saveSettings(newSettings) + } + + return { settings, updateSettings, loading } +} diff --git a/apps/expo-github-mobile/src/models/types.ts b/apps/expo-github-mobile/src/models/types.ts index 602dbedd7..2cd556d59 100644 --- a/apps/expo-github-mobile/src/models/types.ts +++ b/apps/expo-github-mobile/src/models/types.ts @@ -5,6 +5,7 @@ export interface Repository { name: string owner: string defaultBranch: string + language?: string } export const getFullName = (repo: Repository) => `${repo.owner}/${repo.name}` diff --git a/apps/expo-github-mobile/src/services/gateway.ts b/apps/expo-github-mobile/src/services/gateway.ts new file mode 100644 index 000000000..2ebeb094b --- /dev/null +++ b/apps/expo-github-mobile/src/services/gateway.ts @@ -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 +} + +export class GatewayClient { + private ws: WebSocket | null = null + private pending = new Map void + reject: (err: Error) => void + }>() + private eventHandlers = new Map void>>() + private reconnectTimer: ReturnType | null = null + private reconnectDelay = 1000 + private messageQueue: string[] = [] + + constructor(private url: string) {} + + connect(): Promise { + 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): Promise { + 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 = {}): Promise { + 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 + } +} diff --git a/apps/expo-github-mobile/src/services/github.ts b/apps/expo-github-mobile/src/services/github.ts new file mode 100644 index 000000000..6906b2433 --- /dev/null +++ b/apps/expo-github-mobile/src/services/github.ts @@ -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 { + 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 { + 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 { + 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 + } + } +} diff --git a/apps/expo-github-mobile/src/views/SessionListView.tsx b/apps/expo-github-mobile/src/views/SessionListView.tsx index c548f1ebb..b111c411d 100644 --- a/apps/expo-github-mobile/src/views/SessionListView.tsx +++ b/apps/expo-github-mobile/src/views/SessionListView.tsx @@ -17,9 +17,10 @@ import NewSessionSheet from '../components/NewSessionSheet' interface Props { onSessionPress: (session: Session) => void + onSettingsPress: () => void } -const SessionListView: React.FC = ({ onSessionPress }) => { +const SessionListView: React.FC = ({ onSessionPress, onSettingsPress }) => { const { sessions } = useAppState() const [showNewSessionSheet, setShowNewSessionSheet] = useState(false) @@ -27,8 +28,8 @@ const SessionListView: React.FC = ({ onSessionPress }) => { {/* Header */} - - + + Code diff --git a/apps/expo-github-mobile/src/views/SettingsView.tsx b/apps/expo-github-mobile/src/views/SettingsView.tsx new file mode 100644 index 000000000..13928c898 --- /dev/null +++ b/apps/expo-github-mobile/src/views/SettingsView.tsx @@ -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 = ({ 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 ( + + {/* Header */} + + + + + Settings + + + + {/* Content */} + + {/* GitHub Username */} + + GitHub Username + + Used to fetch your public repositories + + + {/* Gateway URL */} + + Gateway URL + + WebSocket URL of your clawdbot gateway + + + {/* Save Button */} + + Save Settings + + + + ) +} + +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