feat: add Expo GitHub mobile app (React Native port)
Port of SwiftUI iOS app to Expo/React Native for cross-platform support. - Session list with repository info and status - Chat view with collapsible tool executions - Message input with model/repo/branch selection chips - PR creation flow simulation - Dark theme matching Claude Code aesthetic - KeyboardAvoidingView for proper keyboard handling https://claude.ai/code/session_018qmTB96S8xvNRQFrhcvzJp
This commit is contained in:
parent
cffaf0b904
commit
9ddebad13c
41
apps/expo-github-mobile/.gitignore
vendored
Normal file
41
apps/expo-github-mobile/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
32
apps/expo-github-mobile/App.tsx
Normal file
32
apps/expo-github-mobile/App.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React, { useState } from 'react'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { AppProvider } from './src/contexts/AppContext'
|
||||
import { Session } from './src/models/types'
|
||||
import SessionListView from './src/views/SessionListView'
|
||||
import ChatView from './src/views/ChatView'
|
||||
|
||||
export default function App() {
|
||||
const [currentView, setCurrentView] = useState<'list' | 'chat'>('list')
|
||||
const [activeSession, setActiveSession] = useState<Session | null>(null)
|
||||
|
||||
const handleSessionPress = (session: Session) => {
|
||||
setActiveSession(session)
|
||||
setCurrentView('chat')
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
setCurrentView('list')
|
||||
setActiveSession(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<StatusBar style="light" />
|
||||
{currentView === 'list' ? (
|
||||
<SessionListView onSessionPress={handleSessionPress} />
|
||||
) : activeSession ? (
|
||||
<ChatView session={activeSession} onBack={handleBackToList} />
|
||||
) : null}
|
||||
</AppProvider>
|
||||
)
|
||||
}
|
||||
84
apps/expo-github-mobile/README.md
Normal file
84
apps/expo-github-mobile/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# GitHub Mobile App - Expo
|
||||
|
||||
A React Native mobile app inspired by Claude Code, built with Expo. This is a port of the SwiftUI iOS app to React Native/Expo for cross-platform support.
|
||||
|
||||
## Features
|
||||
|
||||
- Session list view with repository info and status
|
||||
- Chat view with collapsible tool executions (Write, Bash, Read, Edit, Glob, Grep)
|
||||
- Message input with model/repo/branch selection chips
|
||||
- PR creation flow simulation
|
||||
- Dark theme matching Claude Code aesthetic
|
||||
- Mock data for repos, sessions, and tool calls
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React Native with Expo SDK 54
|
||||
- TypeScript
|
||||
- React Context for state management
|
||||
- Expo Vector Icons (Ionicons)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable components
|
||||
│ ├── MessageInputView.tsx
|
||||
│ ├── SessionRow.tsx
|
||||
│ ├── MessageView.tsx
|
||||
│ ├── ToolCallView.tsx
|
||||
│ ├── Chip.tsx
|
||||
│ └── NewSessionSheet.tsx
|
||||
├── contexts/ # React Context providers
|
||||
│ └── AppContext.tsx
|
||||
├── models/ # TypeScript types and data
|
||||
│ └── types.ts
|
||||
├── theme/ # Colors, spacing, styling
|
||||
│ ├── colors.ts
|
||||
│ └── types.ts
|
||||
└── views/ # Screen components
|
||||
├── SessionListView.tsx
|
||||
└── ChatView.tsx
|
||||
```
|
||||
|
||||
## Running the App
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npx expo start
|
||||
|
||||
# Run on iOS simulator
|
||||
npm run ios
|
||||
|
||||
# Run on Android emulator
|
||||
npm run android
|
||||
|
||||
# Run on web
|
||||
npm run web
|
||||
```
|
||||
|
||||
## Colors & Theme
|
||||
|
||||
The app uses a dark theme matching Claude Code's aesthetic:
|
||||
- Background: `#141414`
|
||||
- Card: `#292929`
|
||||
- Accent (warm orange): `#D98C33`
|
||||
- Tool colors: Write (green), Bash (blue), Read (purple)
|
||||
|
||||
## Models & Data
|
||||
|
||||
Mock data includes:
|
||||
- 6 repositories (clawdbot, systemss, Awesome-Prompts, etc.)
|
||||
- 7 sessions with various titles
|
||||
- Active session with tool call examples (Write, Bash commands)
|
||||
|
||||
## Port Notes
|
||||
|
||||
This is a direct port of the SwiftUI app at `../ios-github-mobile/`. Key differences:
|
||||
- Uses React Native components instead of SwiftUI
|
||||
- Ionicons instead of SF Symbols
|
||||
- React Context instead of `@Observable`
|
||||
- Modal-based sheets instead of SwiftUI sheets
|
||||
30
apps/expo-github-mobile/app.json
Normal file
30
apps/expo-github-mobile/app.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "expo-github-mobile",
|
||||
"slug": "expo-github-mobile",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/expo-github-mobile/assets/adaptive-icon.png
Normal file
BIN
apps/expo-github-mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/expo-github-mobile/assets/favicon.png
Normal file
BIN
apps/expo-github-mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/expo-github-mobile/assets/icon.png
Normal file
BIN
apps/expo-github-mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/expo-github-mobile/assets/splash-icon.png
Normal file
BIN
apps/expo-github-mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
apps/expo-github-mobile/index.ts
Normal file
8
apps/expo-github-mobile/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
9119
apps/expo-github-mobile/package-lock.json
generated
Normal file
9119
apps/expo-github-mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/expo-github-mobile/package.json
Normal file
27
apps/expo-github-mobile/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "expo-github-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.10.1",
|
||||
"expo": "~54.0.32",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
50
apps/expo-github-mobile/src/components/Chip.tsx
Normal file
50
apps/expo-github-mobile/src/components/Chip.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
|
||||
interface Props {
|
||||
icon?: string
|
||||
text: string
|
||||
secondaryText?: string
|
||||
}
|
||||
|
||||
const Chip: React.FC<Props> = ({ icon, text, secondaryText }) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{icon && <Ionicons name={icon as any} size={16} color={Colors.primaryText} />}
|
||||
{secondaryText ? (
|
||||
<>
|
||||
<Text style={styles.secondaryLabel}>{text}</Text>
|
||||
<Text style={styles.text}>{secondaryText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.text}>{text}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
backgroundColor: Colors.chipBackground,
|
||||
borderRadius: Radius.Full,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.chipBorder,
|
||||
},
|
||||
secondaryLabel: {
|
||||
fontSize: 12,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
})
|
||||
|
||||
export default Chip
|
||||
67
apps/expo-github-mobile/src/components/MessageInputView.tsx
Normal file
67
apps/expo-github-mobile/src/components/MessageInputView.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
placeholder: string
|
||||
onChangeText: (text: string) => void
|
||||
onSend: () => void
|
||||
}
|
||||
|
||||
const MessageInputView: React.FC<Props> = ({ text, placeholder, onChangeText, onSend }) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={text}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={Colors.secondaryText}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { opacity: text.trim() ? 1 : 0.5 }]}
|
||||
onPress={onSend}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
<Ionicons name="arrow-up" size={20} color={Colors.buttonPrimaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: Spacing.MD,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
backgroundColor: Colors.inputBackground,
|
||||
borderRadius: Radius.LG,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
gap: Spacing.SM,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
maxHeight: 120,
|
||||
},
|
||||
sendButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: Colors.buttonPrimary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export default MessageInputView
|
||||
57
apps/expo-github-mobile/src/components/MessageView.tsx
Normal file
57
apps/expo-github-mobile/src/components/MessageView.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, ScrollView, StyleSheet } from 'react-native'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import { Message } from '../models/types'
|
||||
import ToolCallView from './ToolCallView'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
|
||||
const MessageView: React.FC<Props> = ({ message }) => {
|
||||
const [expandedToolCalls, setExpandedToolCalls] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleToolCall = (id: string) => {
|
||||
setExpandedToolCalls((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Message content */}
|
||||
{message.content && (
|
||||
<Text style={styles.messageContent}>{message.content}</Text>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls.map((toolCall) => (
|
||||
<ToolCallView
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
isExpanded={expandedToolCalls.has(toolCall.id)}
|
||||
onToggle={() => toggleToolCall(toolCall.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: Spacing.MD,
|
||||
},
|
||||
messageContent: {
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
paddingHorizontal: Spacing.LG,
|
||||
},
|
||||
})
|
||||
|
||||
export default MessageView
|
||||
280
apps/expo-github-mobile/src/components/NewSessionSheet.tsx
Normal file
280
apps/expo-github-mobile/src/components/NewSessionSheet.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import { useAppState } from '../contexts/AppContext'
|
||||
import {
|
||||
Repository,
|
||||
Session,
|
||||
AIModel,
|
||||
AIModels,
|
||||
allModels,
|
||||
} from '../models/types'
|
||||
import MessageInputView from './MessageInputView'
|
||||
import Chip from './Chip'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSessionCreate: (session: Session) => void
|
||||
}
|
||||
|
||||
const NewSessionSheet: React.FC<Props> = ({ visible, onClose, onSessionCreate }) => {
|
||||
const { repositories, createNewSession, sendMessage } = useAppState()
|
||||
const [messageText, setMessageText] = useState('')
|
||||
const [selectedRepository, setSelectedRepository] = useState<Repository | undefined>()
|
||||
const [selectedModel, setSelectedModel] = useState<AIModel>(AIModels.sonnet)
|
||||
const [showModelPicker, setShowModelPicker] = useState(false)
|
||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && repositories.length > 0 && !selectedRepository) {
|
||||
setSelectedRepository(repositories[0])
|
||||
}
|
||||
}, [visible, repositories])
|
||||
|
||||
const handleCreateSession = () => {
|
||||
if (!messageText.trim() || !selectedRepository) return
|
||||
|
||||
const session = createNewSession(messageText.slice(0, 50), selectedRepository)
|
||||
sendMessage(messageText, session)
|
||||
setMessageText('')
|
||||
onSessionCreate(session)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={styles.cancelButton}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>New Session</Text>
|
||||
<View style={{ width: 50 }} />
|
||||
</View>
|
||||
|
||||
{/* Content with keyboard avoidance */}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.content}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{selectedRepository && (
|
||||
<View style={styles.chip}>
|
||||
<Ionicons name="git-branch-outline" size={16} color={Colors.primaryText} />
|
||||
<Text style={styles.chipText}>{selectedRepository.defaultBranch}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Model Picker Modal */}
|
||||
<PickerModal
|
||||
visible={showModelPicker}
|
||||
onClose={() => setShowModelPicker(false)}
|
||||
title="Select Model"
|
||||
>
|
||||
{allModels.map((model) => (
|
||||
<PickerItem
|
||||
key={model.id}
|
||||
title={model.displayName}
|
||||
selected={selectedModel.id === model.id}
|
||||
onPress={() => {
|
||||
setSelectedModel(model)
|
||||
setShowModelPicker(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PickerModal>
|
||||
|
||||
{/* Repository Picker Modal */}
|
||||
<PickerModal
|
||||
visible={showRepoPicker}
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
title="Select Repository"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<PickerItem
|
||||
key={repo.id}
|
||||
title={repo.name}
|
||||
subtitle={`${repo.owner}/${repo.name}`}
|
||||
selected={selectedRepository?.id === repo.id}
|
||||
onPress={() => {
|
||||
setSelectedRepository(repo)
|
||||
setShowRepoPicker(false)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</PickerModal>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// Picker Modal Component
|
||||
const PickerModal: React.FC<{
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}> = ({ visible, onClose, title, children }) => (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<SafeAreaView style={styles.pickerContainer}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={styles.cancelButton}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{title}</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
<ScrollView>{children}</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
// Picker Item Component
|
||||
const PickerItem: React.FC<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
}> = ({ title, subtitle, selected, onPress }) => (
|
||||
<TouchableOpacity style={styles.pickerItem} onPress={onPress}>
|
||||
<View>
|
||||
<Text style={styles.pickerItemTitle}>{title}</Text>
|
||||
{subtitle && <Text style={styles.pickerItemSubtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
{selected && <Ionicons name="checkmark" size={20} color={Colors.accent} />}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.MD,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.surfaceBackground,
|
||||
},
|
||||
cancelButton: {
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
inputContainer: {
|
||||
paddingBottom: Spacing.LG,
|
||||
},
|
||||
chipsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: Spacing.SM,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingTop: Spacing.MD,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
backgroundColor: Colors.chipBackground,
|
||||
borderRadius: Radius.Full,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.chipBorder,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 14,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
pickerContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.background,
|
||||
},
|
||||
pickerHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.MD,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.surfaceBackground,
|
||||
},
|
||||
pickerItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.MD,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: Colors.surfaceBackground,
|
||||
},
|
||||
pickerItemTitle: {
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
pickerItemSubtitle: {
|
||||
fontSize: 13,
|
||||
color: Colors.secondaryText,
|
||||
marginTop: 2,
|
||||
},
|
||||
})
|
||||
|
||||
export default NewSessionSheet
|
||||
47
apps/expo-github-mobile/src/components/SessionRow.tsx
Normal file
47
apps/expo-github-mobile/src/components/SessionRow.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing } from '../theme/colors'
|
||||
import { Session, getFullName } from '../models/types'
|
||||
|
||||
interface Props {
|
||||
session: Session
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
const SessionRow: React.FC<Props> = ({ session, onPress }) => {
|
||||
return (
|
||||
<TouchableOpacity style={styles.container} onPress={onPress}>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{session.title}
|
||||
</Text>
|
||||
<Text style={styles.repoName}>{getFullName(session.repository)}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={Colors.tertiaryText} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.MD,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
gap: Spacing.XS,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
repoName: {
|
||||
fontSize: 14,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
})
|
||||
|
||||
export default SessionRow
|
||||
81
apps/expo-github-mobile/src/components/ToolCallView.tsx
Normal file
81
apps/expo-github-mobile/src/components/ToolCallView.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import { ToolCall } from '../models/types'
|
||||
import { getToolColor } from '../theme/types'
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
const ToolCallView: React.FC<Props> = ({ toolCall, isExpanded, onToggle }) => {
|
||||
const toolColor = getToolColor(toolCall.type)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<TouchableOpacity style={styles.header} onPress={onToggle}>
|
||||
<Text style={[styles.toolType, { color: toolColor }]}>{toolCall.type}</Text>
|
||||
<Text style={styles.toolInput} numberOfLines={1}>
|
||||
{toolCall.input}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size={12}
|
||||
color={Colors.tertiaryText}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && toolCall.output && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.outputContainer}
|
||||
>
|
||||
<Text style={styles.outputText}>{toolCall.output}</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: Colors.cardBackground,
|
||||
borderRadius: Radius.MD,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: Spacing.LG,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.SM,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
},
|
||||
toolType: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
toolInput: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
outputContainer: {
|
||||
backgroundColor: Colors.surfaceBackground,
|
||||
maxHeight: 200,
|
||||
},
|
||||
outputText: {
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 11,
|
||||
color: Colors.secondaryText,
|
||||
padding: Spacing.MD,
|
||||
},
|
||||
})
|
||||
|
||||
export default ToolCallView
|
||||
104
apps/expo-github-mobile/src/contexts/AppContext.tsx
Normal file
104
apps/expo-github-mobile/src/contexts/AppContext.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import {
|
||||
Session,
|
||||
Repository,
|
||||
Message,
|
||||
MessageRole,
|
||||
AIModel,
|
||||
mockSessions,
|
||||
mockRepositories,
|
||||
mockActiveSession,
|
||||
SessionStatus,
|
||||
} from '../models/types'
|
||||
|
||||
interface AppContextType {
|
||||
sessions: Session[]
|
||||
repositories: Repository[]
|
||||
activeSession: Session | null
|
||||
openSession: (session: Session) => void
|
||||
closeSession: () => void
|
||||
createNewSession: (title: string, repository: Repository) => Session
|
||||
sendMessage: (content: string, toSession: Session) => void
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined)
|
||||
|
||||
export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([mockActiveSession, ...mockSessions])
|
||||
const [repositories] = useState<Repository[]>(mockRepositories)
|
||||
const [activeSession, setActiveSession] = useState<Session | null>(null)
|
||||
|
||||
const openSession = useCallback((session: Session) => {
|
||||
setActiveSession(session)
|
||||
}, [])
|
||||
|
||||
const closeSession = useCallback(() => {
|
||||
setActiveSession(null)
|
||||
}, [])
|
||||
|
||||
const createNewSession = useCallback(
|
||||
(title: string, repository: Repository): Session => {
|
||||
const newSession: Session = {
|
||||
id: `session-${Date.now()}`,
|
||||
title,
|
||||
repository,
|
||||
createdAt: new Date(),
|
||||
status: SessionStatus.Running,
|
||||
messages: [],
|
||||
}
|
||||
setSessions((prev) => [newSession, ...prev])
|
||||
return newSession
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const sendMessage = useCallback((content: string, toSession: Session) => {
|
||||
const newMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: MessageRole.User,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id === toSession.id) {
|
||||
return {
|
||||
...s,
|
||||
messages: [...s.messages, newMessage],
|
||||
}
|
||||
}
|
||||
return s
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
sessions,
|
||||
repositories,
|
||||
activeSession,
|
||||
openSession,
|
||||
closeSession,
|
||||
createNewSession,
|
||||
sendMessage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAppState = () => {
|
||||
const context = useContext(AppContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAppState must be used within an AppProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { Session, Repository, Message, AIModel }
|
||||
export { MessageRole, SessionStatus, ToolType, ToolCallStatus } from '../models/types'
|
||||
229
apps/expo-github-mobile/src/models/types.ts
Normal file
229
apps/expo-github-mobile/src/models/types.ts
Normal file
@ -0,0 +1,229 @@
|
||||
// MARK: - Repository
|
||||
|
||||
export interface Repository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
defaultBranch: string
|
||||
}
|
||||
|
||||
export const getFullName = (repo: Repository) => `${repo.owner}/${repo.name}`
|
||||
|
||||
export const mockRepositories: Repository[] = [
|
||||
{ id: '1', name: 'clawdbot', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
{ id: '2', name: 'systemss', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
{ id: '3', name: 'Awesome-Prompts', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
{ id: '4', name: 'statue', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
{ id: '5', name: 'littleagents', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
{ id: '6', name: 'ultimateaiscraper', owner: 'semihpolat', defaultBranch: 'main' },
|
||||
]
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
export enum SessionStatus {
|
||||
Idle = 'Idle',
|
||||
Running = 'Running',
|
||||
Completed = 'Completed',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
title: string
|
||||
repository: Repository
|
||||
createdAt: Date
|
||||
status: SessionStatus
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
// MARK: - Message
|
||||
|
||||
export enum MessageRole {
|
||||
User = 'user',
|
||||
Assistant = 'assistant',
|
||||
System = 'system',
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: MessageRole
|
||||
content: string
|
||||
timestamp: Date
|
||||
toolCalls: ToolCall[]
|
||||
}
|
||||
|
||||
// MARK: - Tool Call
|
||||
|
||||
export enum ToolType {
|
||||
Write = 'Write',
|
||||
Bash = 'Bash',
|
||||
Read = 'Read',
|
||||
Edit = 'Edit',
|
||||
Glob = 'Glob',
|
||||
Grep = 'Grep',
|
||||
}
|
||||
|
||||
export enum ToolCallStatus {
|
||||
Pending = 'pending',
|
||||
Running = 'running',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
type: ToolType
|
||||
name: string
|
||||
input: string
|
||||
output?: string
|
||||
status: ToolCallStatus
|
||||
}
|
||||
|
||||
// MARK: - AI Model
|
||||
|
||||
export interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export const AIModels = {
|
||||
sonnet: { id: 'sonnet-4.5', name: 'sonnet', displayName: 'Sonnet 4.5' } as AIModel,
|
||||
opus: { id: 'opus-4.5', name: 'opus', displayName: 'Opus 4.5' } as AIModel,
|
||||
haiku: { id: 'haiku', name: 'haiku', displayName: 'Haiku' } as AIModel,
|
||||
}
|
||||
|
||||
export const allModels = [AIModels.sonnet, AIModels.opus, AIModels.haiku]
|
||||
|
||||
// MARK: - Mock Data
|
||||
|
||||
const now = new Date()
|
||||
|
||||
export const mockSessions: Session[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Design Claude Code system architecture for mobile',
|
||||
repository: mockRepositories[1],
|
||||
createdAt: new Date(now.getTime() - 3600 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Add popular tweets with images to platform showcase',
|
||||
repository: mockRepositories[2],
|
||||
createdAt: new Date(now.getTime() - 7200 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Add popular tweets with images to platform library',
|
||||
repository: mockRepositories[2],
|
||||
createdAt: new Date(now.getTime() - 10800 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Evaluate migrating SvelteKit project to Next.js',
|
||||
repository: mockRepositories[3],
|
||||
createdAt: new Date(now.getTime() - 14400 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Update resources list with current tools and links',
|
||||
repository: mockRepositories[4],
|
||||
createdAt: new Date(now.getTime() - 18000 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Sync fork with original repository',
|
||||
repository: mockRepositories[3],
|
||||
createdAt: new Date(now.getTime() - 21600 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Improve UI Design with Shadcn Style',
|
||||
repository: mockRepositories[5],
|
||||
createdAt: new Date(now.getTime() - 25200 * 1000),
|
||||
status: SessionStatus.Idle,
|
||||
messages: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const mockActiveSession: Session = {
|
||||
id: 'active-1',
|
||||
title: 'Add popular tweets with images to platform',
|
||||
repository: mockRepositories[2],
|
||||
createdAt: new Date(now.getTime() - 1800 * 1000),
|
||||
status: SessionStatus.Running,
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
role: MessageRole.Assistant,
|
||||
content: 'Haklisın, hemen README.md\'yi oluşturup push yapıyorum!',
|
||||
timestamp: new Date(now.getTime() - 1200 * 1000),
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tc1',
|
||||
type: ToolType.Write,
|
||||
name: 'Write',
|
||||
input: '/home/user/Awesome-Prompts/README.md',
|
||||
output: `+ # Awesome AI Prompts
|
||||
+
|
||||
+ A curated collection of powerful AI prompts that went viral on X/Twitter.
|
||||
+ These prompts have been battle-tested by thousands of users and proven to deliver
|
||||
+ exceptional results with ChatGPT, Claude, and other LLMs.
|
||||
+`,
|
||||
status: ToolCallStatus.Completed,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
role: MessageRole.Assistant,
|
||||
content: 'Şimdi commit ve push yapıyorum:',
|
||||
timestamp: new Date(now.getTime() - 900 * 1000),
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tc2',
|
||||
type: ToolType.Bash,
|
||||
name: 'Bash',
|
||||
input: 'git add README.md && git status',
|
||||
status: ToolCallStatus.Completed,
|
||||
},
|
||||
{
|
||||
id: 'tc3',
|
||||
type: ToolType.Bash,
|
||||
name: 'Bash',
|
||||
input: `git commit -m "$(cat <<'EOF'
|
||||
Add comprehensive Awesome AI Prompts collection...
|
||||
EOF
|
||||
)"`,
|
||||
status: ToolCallStatus.Completed,
|
||||
},
|
||||
{
|
||||
id: 'tc4',
|
||||
type: ToolType.Bash,
|
||||
name: 'Bash',
|
||||
input: 'git push -u origin claude/add-popular-tweets-dy04J',
|
||||
status: ToolCallStatus.Completed,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
role: MessageRole.Assistant,
|
||||
content: 'Push tamam! Şimdi PR oluşturuyorum:',
|
||||
timestamp: new Date(now.getTime() - 600 * 1000),
|
||||
toolCalls: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
53
apps/expo-github-mobile/src/theme/colors.ts
Normal file
53
apps/expo-github-mobile/src/theme/colors.ts
Normal file
@ -0,0 +1,53 @@
|
||||
// Theme colors matching SwiftUI AppTheme
|
||||
export const Colors = {
|
||||
// Background colors
|
||||
background: '#141414', // rgb(0.08, 0.08, 0.08)
|
||||
surfaceBackground: '#1E1E1E', // rgb(0.12, 0.12, 0.12)
|
||||
cardBackground: '#292929', // rgb(0.16, 0.16, 0.16)
|
||||
inputBackground: '#242424', // rgb(0.14, 0.14, 0.14)
|
||||
|
||||
// Text colors
|
||||
primaryText: '#FFFFFF',
|
||||
secondaryText: '#999999', // rgb(0.6, 0.6, 0.6)
|
||||
tertiaryText: '#737373', // rgb(0.45, 0.45, 0.45)
|
||||
|
||||
// Accent colors
|
||||
accent: '#D98C33', // rgb(0.85, 0.55, 0.35) - Warm orange like Claude
|
||||
accentLight: '#F3C08D', // rgb(0.95, 0.75, 0.55)
|
||||
|
||||
// Status colors
|
||||
success: '#4DB366', // rgb(0.3, 0.7, 0.4)
|
||||
warning: '#E6B34D', // rgb(0.9, 0.7, 0.3)
|
||||
error: '#E66666', // rgb(0.9, 0.4, 0.4)
|
||||
|
||||
// Tool colors
|
||||
toolWrite: '#66B266', // rgb(0.4, 0.7, 0.4)
|
||||
toolBash: '#8099CC', // rgb(0.5, 0.6, 0.8)
|
||||
toolRead: '#B299B2', // rgb(0.7, 0.6, 0.8)
|
||||
|
||||
// Chip/Tag colors
|
||||
chipBackground: '#333333', // rgb(0.2, 0.2, 0.2)
|
||||
chipBorder: '#4D4D4D', // rgb(0.3, 0.3, 0.3)
|
||||
|
||||
// Button colors
|
||||
buttonPrimary: '#F2F2F2', // rgb(0.95, 0.95, 0.95)
|
||||
buttonPrimaryText: '#000000',
|
||||
} as const
|
||||
|
||||
// Spacing
|
||||
export const Spacing = {
|
||||
XS: 4,
|
||||
SM: 8,
|
||||
MD: 12,
|
||||
LG: 16,
|
||||
XL: 24,
|
||||
} as const
|
||||
|
||||
// Corner radius
|
||||
export const Radius = {
|
||||
SM: 6,
|
||||
MD: 10,
|
||||
LG: 16,
|
||||
XL: 24,
|
||||
Full: 100,
|
||||
} as const
|
||||
17
apps/expo-github-mobile/src/theme/types.ts
Normal file
17
apps/expo-github-mobile/src/theme/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// Helper to get tool color
|
||||
import { Colors } from './colors'
|
||||
import { ToolType } from '../models/types'
|
||||
|
||||
export const getToolColor = (type: ToolType): string => {
|
||||
switch (type) {
|
||||
case ToolType.Write:
|
||||
case ToolType.Edit:
|
||||
return Colors.toolWrite
|
||||
case ToolType.Bash:
|
||||
return Colors.toolBash
|
||||
case ToolType.Read:
|
||||
case ToolType.Glob:
|
||||
case ToolType.Grep:
|
||||
return Colors.toolRead
|
||||
}
|
||||
}
|
||||
223
apps/expo-github-mobile/src/views/ChatView.tsx
Normal file
223
apps/expo-github-mobile/src/views/ChatView.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useAppState } from '../contexts/AppContext'
|
||||
import { Session, getFullName } from '../models/types'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import MessageView from '../components/MessageView'
|
||||
import MessageInputView from '../components/MessageInputView'
|
||||
import Chip from '../components/Chip'
|
||||
|
||||
interface Props {
|
||||
session: Session
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const ChatView: React.FC<Props> = ({ session, onBack }) => {
|
||||
const { sessions, sendMessage } = useAppState()
|
||||
const [messageText, setMessageText] = useState('')
|
||||
const [showCreatePR, setShowCreatePR] = useState(false)
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
|
||||
// Get current session from state to see updates
|
||||
const currentSession = sessions.find((s) => s.id === session.id) || session
|
||||
|
||||
const hasToolCalls = currentSession.messages.some((m) => m.toolCalls.length > 0)
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (messageText.trim()) {
|
||||
sendMessage(messageText, currentSession)
|
||||
setMessageText('')
|
||||
}
|
||||
}
|
||||
|
||||
// Auto scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollViewRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true })
|
||||
}, 100)
|
||||
}
|
||||
}, [currentSession.messages.length])
|
||||
|
||||
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>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{currentSession.title.length > 30
|
||||
? currentSession.title.slice(0, 30) + '...'
|
||||
: currentSession.title}
|
||||
</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{getFullName(currentSession.repository)} · Default
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.menuButton}>
|
||||
<Ionicons name="ellipsis-horizontal-circle" size={24} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Messages + Input with keyboard avoidance */}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.messagesContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{currentSession.messages.map((message) => (
|
||||
<MessageView key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Create PR button */}
|
||||
{hasToolCalls && (
|
||||
<View style={styles.prButtonContainer}>
|
||||
<Text style={styles.branchName} numberOfLines={1}>
|
||||
claude/add-popular-tweets-dy04J
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.createPRButton} onPress={() => setShowCreatePR(true)}>
|
||||
<Text style={styles.createPRText}>Create PR</Text>
|
||||
<Ionicons name="open-outline" size={14} color={Colors.primaryText} />
|
||||
<Ionicons name="ellipsis-horizontal" size={14} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Input area */}
|
||||
<View style={styles.inputContainer}>
|
||||
<MessageInputView
|
||||
text={messageText}
|
||||
placeholder="Add feedback..."
|
||||
onChangeText={setMessageText}
|
||||
onSend={handleSendMessage}
|
||||
/>
|
||||
|
||||
{/* Bottom chips */}
|
||||
<View style={styles.chipsContainer}>
|
||||
<Chip icon="code-working" text="Branch" secondaryText="claude/add-popular-tweets..." />
|
||||
<TouchableOpacity style={styles.createPRChip} onPress={() => setShowCreatePR(true)}>
|
||||
<Text style={styles.chipText}>Create PR</Text>
|
||||
<Ionicons name="open-outline" size={12} color={Colors.primaryText} />
|
||||
<Ionicons name="ellipsis-horizontal" size={12} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</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,
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.SM,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 11,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
menuButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContainer: {
|
||||
padding: Spacing.MD,
|
||||
gap: Spacing.LG,
|
||||
},
|
||||
prButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.cardBackground,
|
||||
borderRadius: Radius.MD,
|
||||
padding: Spacing.MD,
|
||||
marginTop: Spacing.SM,
|
||||
},
|
||||
branchName: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
createPRButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
},
|
||||
createPRText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
inputContainer: {
|
||||
backgroundColor: Colors.background,
|
||||
paddingBottom: Spacing.MD,
|
||||
},
|
||||
chipsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.SM,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingTop: Spacing.MD,
|
||||
},
|
||||
createPRChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.XS,
|
||||
paddingHorizontal: Spacing.MD,
|
||||
paddingVertical: Spacing.SM,
|
||||
backgroundColor: Colors.chipBackground,
|
||||
borderRadius: Radius.MD,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 12,
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
})
|
||||
|
||||
export default ChatView
|
||||
129
apps/expo-github-mobile/src/views/SessionListView.tsx
Normal file
129
apps/expo-github-mobile/src/views/SessionListView.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
} from 'react-native'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useAppState, Session } from '../contexts/AppContext'
|
||||
import { Colors, Spacing, Radius } from '../theme/colors'
|
||||
import { getFullName } from '../models/types'
|
||||
import SessionRow from '../components/SessionRow'
|
||||
import NewSessionSheet from '../components/NewSessionSheet'
|
||||
|
||||
interface Props {
|
||||
onSessionPress: (session: Session) => void
|
||||
}
|
||||
|
||||
const SessionListView: React.FC<Props> = ({ onSessionPress }) => {
|
||||
const { sessions } = useAppState()
|
||||
const [showNewSessionSheet, setShowNewSessionSheet] = useState(false)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.menuButton}>
|
||||
<Ionicons name="ellipsis-horizontal" size={24} color={Colors.primaryText} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Code</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
|
||||
{/* Status indicator */}
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.statusText}>Idle</Text>
|
||||
</View>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
{sessions.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
onPress={() => {
|
||||
onSessionPress(session)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* New session button */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={styles.newSessionButton}
|
||||
onPress={() => setShowNewSessionSheet(true)}
|
||||
>
|
||||
<Text style={styles.newSessionButtonText}>New session</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* New session sheet */}
|
||||
<NewSessionSheet
|
||||
visible={showNewSessionSheet}
|
||||
onClose={() => setShowNewSessionSheet(false)}
|
||||
onSessionCreate={(session) => {
|
||||
setShowNewSessionSheet(false)
|
||||
onSessionPress(session)
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.SM,
|
||||
},
|
||||
menuButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: Colors.primaryText,
|
||||
},
|
||||
statusContainer: {
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingTop: Spacing.MD,
|
||||
paddingBottom: Spacing.SM,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 13,
|
||||
color: Colors.secondaryText,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
backgroundColor: Colors.background,
|
||||
paddingHorizontal: Spacing.LG,
|
||||
paddingVertical: Spacing.MD,
|
||||
},
|
||||
newSessionButton: {
|
||||
backgroundColor: Colors.buttonPrimary,
|
||||
borderRadius: Radius.Full,
|
||||
paddingVertical: Spacing.MD,
|
||||
alignItems: 'center',
|
||||
},
|
||||
newSessionButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: Colors.buttonPrimaryText,
|
||||
},
|
||||
})
|
||||
|
||||
export default SessionListView
|
||||
6
apps/expo-github-mobile/tsconfig.json
Normal file
6
apps/expo-github-mobile/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user