Skip to content

Commit 729ecc8

Browse files
committed
Add Openpad-based mobile app
1 parent bccde60 commit 729ecc8

53 files changed

Lines changed: 5585 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 1267 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"packages/*",
2222
"packages/console/*",
2323
"packages/sdk/js",
24-
"packages/slack"
24+
"packages/slack",
25+
"packages/mobile"
2526
],
2627
"catalog": {
2728
"@types/bun": "1.3.9",
@@ -103,7 +104,9 @@
103104
],
104105
"overrides": {
105106
"@types/bun": "catalog:",
106-
"@types/node": "catalog:"
107+
"@types/node": "catalog:",
108+
"@types/react": "~19.1.10",
109+
"react-native": "0.81.5"
107110
},
108111
"patchedDependencies": {
109112
"ghostty-opentui@1.3.7": "patches/ghostty-opentui@1.3.7.patch",

packages/app/src/context/global-sync/event-reducer.ts

Lines changed: 29 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
SessionStatus,
1212
Todo,
1313
} from "@opencode-ai/sdk/v2/client"
14+
import { applyMessageEvent, type MessageStore } from "@opencode-ai/sdk/event-reducer"
1415
import type { State, VcsCache } from "./types"
1516
import { trimSessions } from "./session-trim"
1617

@@ -164,99 +165,41 @@ export function applyDirectoryEvent(input: {
164165
input.setStore("session_status", props.sessionID, reconcile(props.status))
165166
break
166167
}
167-
case "message.updated": {
168-
const info = (event.properties as { info: Message }).info
169-
const messages = input.store.message[info.sessionID]
170-
if (!messages) {
171-
input.setStore("message", info.sessionID, [info])
172-
break
168+
case "message.updated":
169+
case "message.removed":
170+
case "message.part.updated":
171+
case "message.part.removed":
172+
case "message.part.delta": {
173+
const currentStore: MessageStore = {
174+
messages: input.store.message as Record<string, Message[]>,
175+
parts: input.store.part as Record<string, Part[]>,
173176
}
174-
const result = Binary.search(messages, info.id, (m) => m.id)
175-
if (result.found) {
176-
input.setStore("message", info.sessionID, result.index, reconcile(info))
177-
break
177+
const next = applyMessageEvent(currentStore, event)
178+
if (!next) break
179+
// Apply changed message lists to Solid store
180+
for (const sessionID of Object.keys(next.messages) as string[]) {
181+
if (next.messages[sessionID] !== currentStore.messages[sessionID]) {
182+
input.setStore("message", sessionID, reconcile(next.messages[sessionID], { key: "id" }))
183+
}
178184
}
179-
input.setStore(
180-
"message",
181-
info.sessionID,
182-
produce((draft) => {
183-
draft.splice(result.index, 0, info)
184-
}),
185-
)
186-
break
187-
}
188-
case "message.removed": {
189-
const props = event.properties as { sessionID: string; messageID: string }
190-
input.setStore(
191-
produce((draft) => {
192-
const messages = draft.message[props.sessionID]
193-
if (messages) {
194-
const result = Binary.search(messages, props.messageID, (m) => m.id)
195-
if (result.found) messages.splice(result.index, 1)
196-
}
197-
delete draft.part[props.messageID]
198-
}),
199-
)
200-
break
201-
}
202-
case "message.part.updated": {
203-
const part = (event.properties as { part: Part }).part
204-
const parts = input.store.part[part.messageID]
205-
if (!parts) {
206-
input.setStore("part", part.messageID, [part])
207-
break
185+
for (const sessionID of Object.keys(currentStore.messages) as string[]) {
186+
if (!(sessionID in next.messages)) {
187+
input.setStore(produce((draft) => { delete draft.message[sessionID] }))
188+
}
208189
}
209-
const result = Binary.search(parts, part.id, (p) => p.id)
210-
if (result.found) {
211-
input.setStore("part", part.messageID, result.index, reconcile(part))
212-
break
190+
// Apply changed part lists to Solid store
191+
for (const messageID of Object.keys(next.parts) as string[]) {
192+
if (next.parts[messageID] !== currentStore.parts[messageID]) {
193+
input.setStore("part", messageID, reconcile(next.parts[messageID], { key: "id" }))
194+
}
213195
}
214-
input.setStore(
215-
"part",
216-
part.messageID,
217-
produce((draft) => {
218-
draft.splice(result.index, 0, part)
219-
}),
220-
)
221-
break
222-
}
223-
case "message.part.removed": {
224-
const props = event.properties as { messageID: string; partID: string }
225-
const parts = input.store.part[props.messageID]
226-
if (!parts) break
227-
const result = Binary.search(parts, props.partID, (p) => p.id)
228-
if (result.found) {
229-
input.setStore(
230-
produce((draft) => {
231-
const list = draft.part[props.messageID]
232-
if (!list) return
233-
const next = Binary.search(list, props.partID, (p) => p.id)
234-
if (!next.found) return
235-
list.splice(next.index, 1)
236-
if (list.length === 0) delete draft.part[props.messageID]
237-
}),
238-
)
196+
for (const messageID of Object.keys(currentStore.parts) as string[]) {
197+
if (!(messageID in next.parts)) {
198+
input.setStore(produce((draft) => { delete draft.part[messageID] }))
199+
}
239200
}
240201
break
241202
}
242-
case "message.part.delta": {
243-
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
244-
const parts = input.store.part[props.messageID]
245-
if (!parts) break
246-
const result = Binary.search(parts, props.partID, (p) => p.id)
247-
if (!result.found) break
248-
input.setStore(
249-
"part",
250-
props.messageID,
251-
produce((draft) => {
252-
const part = draft[result.index]
253-
const field = props.field as keyof typeof part
254-
const existing = part[field] as string | undefined
255-
;(part[field] as string) = (existing ?? "") + props.delta
256-
}),
257-
)
258-
break
259-
}
260203
case "vcs.branch.updated": {
261204
const props = event.properties as { branch: string }
262205
if (input.store.vcs?.branch === props.branch) break
-175 KB
Loading

packages/mobile/.gitignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2+
3+
# dependencies
4+
node_modules/
5+
6+
# Expo
7+
.expo/
8+
dist/
9+
web-build/
10+
expo-env.d.ts
11+
12+
# Native
13+
.kotlin/
14+
*.orig.*
15+
*.jks
16+
*.p8
17+
*.p12
18+
*.key
19+
*.mobileprovision
20+
21+
# Metro
22+
.metro-health-check*
23+
24+
# debug
25+
npm-debug.*
26+
yarn-debug.*
27+
yarn-error.*
28+
29+
# macOS
30+
.DS_Store
31+
*.pem
32+
33+
# local env files
34+
.env*.local
35+
36+
# typescript
37+
*.tsbuildinfo
38+
39+
# generated native folders
40+
/ios
41+
/android

packages/mobile/README.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# OpenPad
2+
3+
A mobile client for [OpenCode](https://opencode.ai) - the AI coding assistant. Built with Expo and React Native.
4+
5+
## Features
6+
7+
- Connect to any OpenCode server
8+
- View and manage chat sessions
9+
- Send messages and receive AI responses in real-time
10+
- View tool usage (file reads, edits, bash commands, etc.)
11+
- Expandable tool blocks with input/output details
12+
- Image attachment support
13+
- Dark/light theme support
14+
- Live polling for real-time updates
15+
16+
## Screenshots
17+
18+
*Coming soon*
19+
20+
## Requirements
21+
22+
- Bun 1.3+
23+
- iOS Simulator (Mac) or Android Emulator, or physical device with Expo Go
24+
25+
## Installation
26+
27+
This package is part of the shuvcode monorepo. From the repository root:
28+
29+
```bash
30+
bun install
31+
```
32+
33+
Start the development server:
34+
35+
```bash
36+
bun run --cwd packages/mobile start
37+
```
38+
39+
4. Run on your device:
40+
- **iOS Simulator**: Press `i` in the terminal
41+
- **Android Emulator**: Press `a` in the terminal
42+
- **Physical Device**: Scan the QR code with Expo Go app
43+
44+
## Connecting to OpenCode
45+
46+
1. Make sure you have an OpenCode server running (default: `http://localhost:9034`)
47+
2. If running on a physical device, use your machine's local IP address instead of `localhost`
48+
3. Enter the server URL in the connect screen and tap "Connect"
49+
50+
## Project Structure
51+
52+
```
53+
openpad/
54+
├── App.tsx # Main app entry point with navigation
55+
├── src/
56+
│ ├── components/ # Reusable UI components
57+
│ │ ├── GlassCard.tsx # Glass morphism card component
58+
│ │ ├── Icon.tsx # Icon wrapper for lucide-react-native
59+
│ │ └── Markdown.tsx # Markdown renderer for messages
60+
│ ├── hooks/ # Custom React hooks
61+
│ │ ├── useOpenCode.ts # OpenCode SDK hook (legacy)
62+
│ │ └── useTheme.ts # Theme hook for dark/light mode
63+
│ ├── providers/ # Context providers
64+
│ │ └── OpenCodeProvider.tsx # OpenCode client & state management
65+
│ ├── screens/ # App screens
66+
│ │ ├── ChatScreen.tsx # Chat conversation view
67+
│ │ ├── ConnectScreen.tsx # Server connection screen
68+
│ │ ├── SessionsScreen.tsx # Sessions list
69+
│ │ └── SettingsScreen.tsx # App settings
70+
│ └── theme/ # Theme configuration
71+
│ └── index.ts # Colors, typography, spacing
72+
├── assets/ # App icons and images
73+
└── package.json
74+
```
75+
76+
## Tech Stack
77+
78+
- **Expo SDK 54** - React Native development platform
79+
- **React Navigation** - Native stack navigation
80+
- **@opencode-ai/sdk** - OpenCode API client
81+
- **lucide-react-native** - Icon library
82+
- **expo-blur** - Blur effects for iOS
83+
- **react-native-markdown-display** - Markdown rendering
84+
85+
## Contributing
86+
87+
We welcome contributions! Here's how you can help:
88+
89+
### Getting Started
90+
91+
1. Fork the repository
92+
2. Create a feature branch: `git checkout -b feature/my-feature`
93+
3. Make your changes
94+
4. Run TypeScript check: `bun run typecheck`
95+
5. Commit your changes: `git commit -m "feat: Add my feature"`
96+
6. Push to your fork: `git push origin feature/my-feature`
97+
7. Open a Pull Request
98+
99+
### Commit Convention
100+
101+
We follow [Conventional Commits](https://www.conventionalcommits.org/):
102+
103+
- `feat:` - New features
104+
- `fix:` - Bug fixes
105+
- `docs:` - Documentation changes
106+
- `style:` - Code style changes (formatting, etc.)
107+
- `refactor:` - Code refactoring
108+
- `test:` - Adding or updating tests
109+
- `chore:` - Maintenance tasks
110+
111+
### Code Style
112+
113+
- Use TypeScript for all new code
114+
- Follow existing patterns in the codebase
115+
- Use functional components with hooks
116+
- Keep components small and focused
117+
- Use the theme system for colors and spacing
118+
119+
### Areas for Contribution
120+
121+
- UI/UX improvements
122+
- New features (voice input, image upload, etc.)
123+
- Performance optimizations
124+
- Bug fixes
125+
- Documentation improvements
126+
- Tests
127+
128+
### Reporting Issues
129+
130+
Found a bug or have a feature request? [Open an issue](https://github.com/R44VC0RP/openpad/issues) with:
131+
132+
- Clear description of the problem or feature
133+
- Steps to reproduce (for bugs)
134+
- Expected vs actual behavior
135+
- Screenshots if applicable
136+
- Device/OS information
137+
138+
## License
139+
140+
MIT
141+
142+
## Acknowledgments
143+
144+
- [OpenCode](https://opencode.ai) - The AI coding assistant this app connects to
145+
- [Expo](https://expo.dev) - React Native development platform
146+
- [Lucide](https://lucide.dev) - Beautiful icons

packages/mobile/app.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"expo": {
3+
"name": "OpenPad",
4+
"slug": "openpad",
5+
"version": "1.0.0",
6+
"scheme": "openpad",
7+
"orientation": "default",
8+
"icon": "./assets/icon.png",
9+
"userInterfaceStyle": "automatic",
10+
"newArchEnabled": true,
11+
"splash": {
12+
"image": "./assets/splash-icon.png",
13+
"resizeMode": "contain",
14+
"backgroundColor": "#ffffff"
15+
},
16+
"ios": {
17+
"supportsTablet": true,
18+
"bundleIdentifier": "com.openpad.app"
19+
},
20+
"android": {
21+
"package": "com.openpad.app",
22+
"softwareKeyboardLayoutMode": "resize",
23+
"adaptiveIcon": {
24+
"foregroundImage": "./assets/adaptive-icon.png",
25+
"backgroundColor": "#131010"
26+
},
27+
"edgeToEdgeEnabled": true,
28+
"predictiveBackGestureEnabled": false
29+
},
30+
"web": {
31+
"favicon": "./assets/favicon.png",
32+
"bundler": "metro"
33+
},
34+
"plugins": [
35+
"expo-router",
36+
[
37+
"expo-image-picker",
38+
{
39+
"photosPermission": "Allow OpenPad to access your photos so you can attach images to chats.",
40+
"cameraPermission": "Allow OpenPad to use your camera so you can capture images to attach to chats."
41+
}
42+
]
43+
]
44+
}
45+
}

0 commit comments

Comments
 (0)