Skip to content

Commit 4b24481

Browse files
committed
Merge PR #332: Add Openpad mobile app
2 parents a49ede2 + 729ecc8 commit 4b24481

52 files changed

Lines changed: 5605 additions & 159 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: 1276 additions & 66 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
@@ -22,7 +22,8 @@
2222
"packages/*",
2323
"packages/console/*",
2424
"packages/sdk/js",
25-
"packages/slack"
25+
"packages/slack",
26+
"packages/mobile"
2627
],
2728
"catalog": {
2829
"@types/bun": "1.3.9",
@@ -107,7 +108,9 @@
107108
],
108109
"overrides": {
109110
"@types/bun": "catalog:",
110-
"@types/node": "catalog:"
111+
"@types/node": "catalog:",
112+
"@types/react": "~19.1.10",
113+
"react-native": "0.81.5"
111114
},
112115
"patchedDependencies": {
113116
"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
import { dropSessionCaches } from "./session-cache"
@@ -174,99 +175,41 @@ export function applyDirectoryEvent(input: {
174175
input.setStore("session_status", props.sessionID, reconcile(props.status))
175176
break
176177
}
177-
case "message.updated": {
178-
const info = (event.properties as { info: Message }).info
179-
const messages = input.store.message[info.sessionID]
180-
if (!messages) {
181-
input.setStore("message", info.sessionID, [info])
182-
break
178+
case "message.updated":
179+
case "message.removed":
180+
case "message.part.updated":
181+
case "message.part.removed":
182+
case "message.part.delta": {
183+
const currentStore: MessageStore = {
184+
messages: input.store.message as Record<string, Message[]>,
185+
parts: input.store.part as Record<string, Part[]>,
183186
}
184-
const result = Binary.search(messages, info.id, (m) => m.id)
185-
if (result.found) {
186-
input.setStore("message", info.sessionID, result.index, reconcile(info))
187-
break
187+
const next = applyMessageEvent(currentStore, event)
188+
if (!next) break
189+
// Apply changed message lists to Solid store
190+
for (const sessionID of Object.keys(next.messages) as string[]) {
191+
if (next.messages[sessionID] !== currentStore.messages[sessionID]) {
192+
input.setStore("message", sessionID, reconcile(next.messages[sessionID], { key: "id" }))
193+
}
188194
}
189-
input.setStore(
190-
"message",
191-
info.sessionID,
192-
produce((draft) => {
193-
draft.splice(result.index, 0, info)
194-
}),
195-
)
196-
break
197-
}
198-
case "message.removed": {
199-
const props = event.properties as { sessionID: string; messageID: string }
200-
input.setStore(
201-
produce((draft) => {
202-
const messages = draft.message[props.sessionID]
203-
if (messages) {
204-
const result = Binary.search(messages, props.messageID, (m) => m.id)
205-
if (result.found) messages.splice(result.index, 1)
206-
}
207-
delete draft.part[props.messageID]
208-
}),
209-
)
210-
break
211-
}
212-
case "message.part.updated": {
213-
const part = (event.properties as { part: Part }).part
214-
const parts = input.store.part[part.messageID]
215-
if (!parts) {
216-
input.setStore("part", part.messageID, [part])
217-
break
195+
for (const sessionID of Object.keys(currentStore.messages) as string[]) {
196+
if (!(sessionID in next.messages)) {
197+
input.setStore(produce((draft) => { delete draft.message[sessionID] }))
198+
}
218199
}
219-
const result = Binary.search(parts, part.id, (p) => p.id)
220-
if (result.found) {
221-
input.setStore("part", part.messageID, result.index, reconcile(part))
222-
break
200+
// Apply changed part lists to Solid store
201+
for (const messageID of Object.keys(next.parts) as string[]) {
202+
if (next.parts[messageID] !== currentStore.parts[messageID]) {
203+
input.setStore("part", messageID, reconcile(next.parts[messageID], { key: "id" }))
204+
}
223205
}
224-
input.setStore(
225-
"part",
226-
part.messageID,
227-
produce((draft) => {
228-
draft.splice(result.index, 0, part)
229-
}),
230-
)
231-
break
232-
}
233-
case "message.part.removed": {
234-
const props = event.properties as { messageID: string; partID: string }
235-
const parts = input.store.part[props.messageID]
236-
if (!parts) break
237-
const result = Binary.search(parts, props.partID, (p) => p.id)
238-
if (result.found) {
239-
input.setStore(
240-
produce((draft) => {
241-
const list = draft.part[props.messageID]
242-
if (!list) return
243-
const next = Binary.search(list, props.partID, (p) => p.id)
244-
if (!next.found) return
245-
list.splice(next.index, 1)
246-
if (list.length === 0) delete draft.part[props.messageID]
247-
}),
248-
)
206+
for (const messageID of Object.keys(currentStore.parts) as string[]) {
207+
if (!(messageID in next.parts)) {
208+
input.setStore(produce((draft) => { delete draft.part[messageID] }))
209+
}
249210
}
250211
break
251212
}
252-
case "message.part.delta": {
253-
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
254-
const parts = input.store.part[props.messageID]
255-
if (!parts) break
256-
const result = Binary.search(parts, props.partID, (p) => p.id)
257-
if (!result.found) break
258-
input.setStore(
259-
"part",
260-
props.messageID,
261-
produce((draft) => {
262-
const part = draft[result.index]
263-
const field = props.field as keyof typeof part
264-
const existing = part[field] as string | undefined
265-
;(part[field] as string) = (existing ?? "") + props.delta
266-
}),
267-
)
268-
break
269-
}
270213
case "vcs.branch.updated": {
271214
const props = event.properties as { branch: string }
272215
if (input.store.vcs?.branch === props.branch) break

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)