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:
Claude 2026-01-23 13:42:24 -08:00 committed by semihpolat
parent cffaf0b904
commit 9ddebad13c
24 changed files with 10684 additions and 0 deletions

41
apps/expo-github-mobile/.gitignore vendored Normal file
View 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

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

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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

File diff suppressed because it is too large Load Diff

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

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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: [],
},
],
}

View 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

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

View 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

View 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

View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}