commit 1843a7c37e775ac4acf6e9d035a7b08f59f206a8 Author: Ubuntu Date: Sun May 17 19:52:54 2026 +0000 Initial commit: OpenCode mobile app (extracted from opencode monorepo) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b4be5fe --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,59 @@ +name: Build Android APK + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install dependencies + run: npm install + + - name: Expo prebuild + run: npx expo prebuild --platform android --no-install + + - name: Build APK + working-directory: android + run: ./gradlew assembleRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: app-release + path: android/app/build/outputs/apk/release/app-release.apk + + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: app-release + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: app-release.apk + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c41e83b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.expo/ +android/.gradle/ +android/app/build/ +android/build/ +ios/Pods/ +*.apk +*.aab +*.jks +*.keystore +.env +.env.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5fcbe6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Mobile App - AGENTS.md + +## Overview + +React Native / Expo mobile client for opencode. Connects to an opencode server instance via HTTP + SSE for real-time updates. + +## Architecture + +``` +packages/mobile/ +├── app/ # Expo Router file-based routing +│ ├── (tabs)/ # Tab navigation (sessions, connections, settings) +│ ├── session/[id].tsx # Chat screen +│ └── connection/ # Add/edit connection screens +├── src/ +│ ├── components/ # Reusable UI components +│ │ ├── markdown/ # Markdown renderer (wraps react-native-marked) +│ │ └── AuthGate.tsx # Biometric auth gate +│ ├── lib/ +│ │ ├── sdk.ts # HTTP + SSE client for opencode server API +│ │ └── types.ts # Re-exported types +│ └── stores/ # Zustand state stores +│ ├── sessions.ts # Session list, messages, parts +│ ├── connections.ts # Server connections, client lifecycle +│ ├── events.ts # SSE event stream, status tracking, permissions, questions +│ └── auth.ts # Biometric auth +``` + +## Key Patterns + +- **SSE for real-time**: The `events.ts` store connects to `/global/event` and dispatches to other stores +- **Fire-and-forget sends**: `sendMessage` posts to the API but doesn't await response; SSE events drive all UI updates +- **Session status**: Derived from `session.status` events (`idle`/`busy`/`retry`) + last part type for status text +- **Markdown**: `react-native-marked` wrapped in our own `Markdown` component with custom `CodeBlock` (copy button). Designed to be swappable/publishable later. + +## Style Guide + +Follow the root repo AGENTS.md style guide: + +- Prefer `const` over `let` +- Avoid `else` statements, use early returns +- Prefer single-word variable names +- Avoid `try/catch` where possible +- Avoid `any` type +- Use Bun APIs where applicable (for scripts, not in RN runtime) + +## Running + +```bash +cd packages/mobile +bun install +bun start # Expo dev server +bun run ios # iOS simulator +bun run android # Android emulator +``` + +## Connecting + +Run `opencode serve --hostname 0.0.0.0 --port 4096` on your machine, then add a connection in the app with your machine's local IP and port 4096. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fbcd98 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# OpenCode Mobile + +A React Native / Expo app for connecting to OpenCode servers from your phone. + +## Features + +- **Multiple Connection Types**: Connect via local network, tunnels (Cloudflare/ngrok), or cloud-hosted instances +- **Secure Authentication**: Biometric authentication (Face ID/Touch ID) support +- **Session Management**: View, create, and manage coding sessions +- **Real-time Chat**: Stream responses from your AI assistant +- **File Diff Viewer**: See what changes were made to your code + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- Bun (recommended) or npm +- Expo Go app on your phone (for development) + +### Installation + +```bash +# From the monorepo root +cd packages/mobile +bun install + +# Start the development server +bun start +``` + +### Connecting to OpenCode + +1. Start OpenCode in server mode on your machine: + + ```bash + OPENCODE_SERVER_PASSWORD=yourpassword opencode serve --hostname 0.0.0.0 --port 4096 + ``` + +2. Open the app and add a connection: + - **Local Network**: Use your machine's local IP (e.g., `http://192.168.1.100:4096`) + - **Tunnel**: Set up a Cloudflare Tunnel or ngrok and use the tunnel URL + - **Cloud**: Connect to a hosted OpenCode instance + +## Building for Production + +### iOS + +```bash +bun run ios +# or +eas build --platform ios +``` + +### Android + +```bash +bun run android +# or +eas build --platform android +``` + +## Security + +- Credentials are stored securely using `expo-secure-store` (iOS Keychain / Android Keystore) +- Optional biometric authentication for app access +- Optional biometric confirmation for sending messages +- All traffic should use HTTPS for non-local connections + +## Architecture + +``` +packages/mobile/ +├── app/ # Expo Router screens +│ ├── (tabs)/ # Tab navigation +│ ├── session/ # Session screens +│ └── connection/ # Connection management +├── src/ +│ ├── components/ # Reusable components +│ ├── hooks/ # Custom hooks +│ ├── lib/ # SDK client & types +│ └── stores/ # Zustand state stores +└── assets/ # App icons & images +``` + +## Contributing + +This is part of the OpenCode monorepo. See the root README for contribution guidelines. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..93f41d1 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,182 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'ai.opencode.mobile' + defaultConfig { + applicationId 'ai.opencode.mobile' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce3631b --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/ai/opencode/mobile/MainActivity.kt b/android/app/src/main/java/ai/opencode/mobile/MainActivity.kt new file mode 100644 index 0000000..7eac09e --- /dev/null +++ b/android/app/src/main/java/ai/opencode/mobile/MainActivity.kt @@ -0,0 +1,61 @@ +package ai.opencode.mobile + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/android/app/src/main/java/ai/opencode/mobile/MainApplication.kt b/android/app/src/main/java/ai/opencode/mobile/MainApplication.kt new file mode 100644 index 0000000..84f8ad2 --- /dev/null +++ b/android/app/src/main/java/ai/opencode/mobile/MainApplication.kt @@ -0,0 +1,56 @@ +package ai.opencode.mobile + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..31df827 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..ef243aa Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..e9d5474 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..d61da15 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..4aeed11 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..a2f5908 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b52399 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..ff10afd Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..115a4c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..dcd3cd8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..459ca60 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..8ca12fe Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8e19b41 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..b824ebd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4c19a13 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..3c05de5 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a890727 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FFFFFF + #023c69 + #ffffff + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..70e4cb5 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + OpenCode + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb4ceb7 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..8e39f82 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,65 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..7f94d3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..b2d18e0 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'OpenCode' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/app.json b/app.json new file mode 100644 index 0000000..32669b9 --- /dev/null +++ b/app.json @@ -0,0 +1,29 @@ +{ + "expo": { + "name": "OpenCode", + "slug": "opencode-mobile", + "version": "1.0.0", + "orientation": "portrait", + "scheme": "opencode", + "userInterfaceStyle": "automatic", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "ai.opencode.mobile", + "infoPlist": { + "NSFaceIDUsageDescription": "Use Face ID to unlock OpenCode and access your sessions securely." + } + }, + "android": { + "package": "ai.opencode.mobile" + }, + "web": { + "bundler": "metro", + "output": "static" + }, + "plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..a3881f5 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,47 @@ +import { Tabs } from "expo-router" +import { useColorScheme } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +export default function TabLayout() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + + ) +} diff --git a/app/(tabs)/connections.tsx b/app/(tabs)/connections.tsx new file mode 100644 index 0000000..68ea71f --- /dev/null +++ b/app/(tabs)/connections.tsx @@ -0,0 +1,387 @@ +import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native" +import { router } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useConnections } from "../../src/stores/connections" +import { useSettings } from "../../src/stores/settings" +import type { ServerConnection } from "../../src/lib/types" + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200] as const + +function ConnectionItem({ + connection, + isDark, + isActive, + onSelect, + onEdit, + onDelete, +}: { + connection: ServerConnection + isDark: boolean + isActive: boolean + onSelect: () => void + onEdit: () => void + onDelete: () => void +}) { + const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud" + + const handleLongPress = () => { + Alert.alert(connection.name, "What would you like to do?", [ + { text: "Cancel", style: "cancel" }, + { text: "Edit", onPress: onEdit }, + { text: "Delete", style: "destructive", onPress: onDelete }, + ]) + } + + return ( + + + + + + + + {connection.name} + + {isActive && ( + + Active + + )} + + + {connection.url} + + {connection.lastConnected && ( + + Last connected: {new Date(connection.lastConnected).toLocaleDateString()} + + )} + + + + + + ) +} + +export default function ConnectionsScreen() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections() + const { pageSize, setPageSize } = useSettings() + + const handleDelete = (connection: ServerConnection) => { + Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => removeConnection(connection.id), + }, + ]) + } + + return ( + + item.id} + renderItem={({ item }) => ( + setActiveConnection(item.id)} + onEdit={() => router.push(`/connection/${item.id}`)} + onDelete={() => handleDelete(item)} + /> + )} + ListEmptyComponent={ + + + No Connections + + Add a connection to your OpenCode server + + + } + ListHeaderComponent={ + + Tap to switch, long press for options + + } + ListFooterComponent={ + + Preferences + + + + Messages per page + + + {PAGE_SIZE_OPTIONS.map((size) => ( + setPageSize(size)} + > + + {size} + + + ))} + + + + How many messages to load when opening a session. Lower = faster. + + + } + contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined} + /> + + {/* FAB to add connection */} + router.push("/connection/add")}> + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#ffffff", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + header: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + headerDark: { + borderBottomColor: "#1a1a1a", + }, + headerText: { + fontSize: 13, + color: "#666666", + }, + connectionItem: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + connectionItemDark: { + borderBottomColor: "#1a1a1a", + }, + connectionItemActive: { + backgroundColor: "#f0fdf4", + }, + connectionItemActiveDark: { + backgroundColor: "#1a2e1a", + }, + connectionIcon: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "#f5f5f5", + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + connectionContent: { + flex: 1, + }, + connectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + connectionName: { + fontSize: 16, + fontWeight: "600", + color: "#0a0a0a", + }, + connectionNameActive: { + color: "#0a0a0a", + }, + connectionNameActiveDark: { + color: "#ffffff", + }, + textDark: { + color: "#ffffff", + }, + activeBadge: { + backgroundColor: "#22c55e", + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + activeBadgeDark: { + backgroundColor: "#16a34a", + }, + activeBadgeText: { + color: "#ffffff", + fontSize: 11, + fontWeight: "600", + }, + activeBadgeTextDark: { + color: "#ffffff", + }, + connectionUrl: { + fontSize: 13, + color: "#666666", + marginTop: 2, + }, + connectionUrlActive: { + color: "#888888", + }, + connectionMeta: { + fontSize: 12, + color: "#999999", + marginTop: 4, + }, + metaDark: { + color: "#888888", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 32, + }, + emptyTitle: { + fontSize: 20, + fontWeight: "600", + marginTop: 16, + color: "#0a0a0a", + }, + emptySubtitle: { + fontSize: 14, + color: "#666666", + marginTop: 8, + textAlign: "center", + }, + emptyContent: { + flex: 1, + }, + fab: { + position: "absolute", + right: 16, + bottom: 16, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: "#0a0a0a", + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + fabDark: { + backgroundColor: "#ffffff", + }, + settingsSection: { + padding: 16, + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + marginTop: 16, + gap: 10, + }, + settingsSectionDark: { + borderTopColor: "#1a1a1a", + }, + settingsTitle: { + fontSize: 15, + fontWeight: "700", + color: "#0a0a0a", + }, + settingRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + settingLabel: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + settingText: { + fontSize: 14, + color: "#0a0a0a", + }, + pagePicker: { + flexDirection: "row", + gap: 6, + }, + pageOption: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + borderWidth: 1, + borderColor: "#e5e5e5", + backgroundColor: "#f5f5f5", + }, + pageOptionDark: { + borderColor: "#2a2a2a", + backgroundColor: "#1a1a1a", + }, + pageOptionActive: { + backgroundColor: "#0a0a0a", + borderColor: "#0a0a0a", + }, + pageOptionActiveDark: { + backgroundColor: "#3b82f6", + borderColor: "#3b82f6", + }, + pageOptionText: { + fontSize: 13, + fontWeight: "500", + color: "#666666", + }, + pageOptionTextActive: { + color: "#ffffff", + }, + settingHint: { + fontSize: 12, + color: "#999999", + }, +}) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..eefa6e1 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,836 @@ +import { useEffect, useCallback, useState, useRef } from "react" +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + useColorScheme, + RefreshControl, + ActivityIndicator, + Modal, + TextInput, + Alert, + KeyboardAvoidingView, + Platform, +} from "react-native" +import { router } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useSessions } from "../../src/stores/sessions" +import { useConnections } from "../../src/stores/connections" +import type BottomSheet from "@gorhom/bottom-sheet" +import type { Session } from "../../src/lib/sdk" +import { DirectorySwitcher } from "../../src/components/chat" + +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + const now = new Date() + const diff = now.getTime() - date.getTime() + + if (diff < 60000) return "Just now" + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` + if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago` + + return date.toLocaleDateString() +} + +function SessionItem({ + session, + isDark, + onRename, + onDelete, +}: { + session: Session + isDark: boolean + onRename: () => void + onDelete: () => void +}) { + const onPress = () => { + router.push(`/session/${session.id}`) + } + + const onLongPress = () => { + Alert.alert(session.title || "Untitled Session", undefined, [ + { text: "Cancel", style: "cancel" }, + { text: "Rename", onPress: onRename }, + { text: "Delete", style: "destructive", onPress: onDelete }, + ]) + } + + // Extract short directory name from session + const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null + + return ( + + + + + {session.title || "Untitled Session"} + + + + + {formatTime(session.time.updated)} + {session.summary && ` · ${session.summary.files} files`} + + {shortDir && ( + + + {shortDir} + + )} + + + + + ) +} + +// Get short directory name (last folder or project name) +function getShortPath( + project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined, +): string { + if (!project) return "" + if (project.name) return project.name + if (!project.path?.absolute) return "" + const parts = project.path.absolute.split("/").filter(Boolean) + return parts[parts.length - 1] || project.path.absolute +} + +export default function SessionsScreen() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + const [showNewSession, setShowNewSession] = useState(false) + const [customDir, setCustomDir] = useState("") + const [isCreating, setIsCreating] = useState(false) + const [renaming, setRenaming] = useState(null) + const [renameText, setRenameText] = useState("") + + const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions() + const { + activeConnection, + client, + currentProject, + serverHome, + refreshProject, + clientForDirectory, + switchDirectory, + addRecentDirectory, + recentDirectories, + } = useConnections() + const dirSheetRef = useRef(null) + const [refreshing, setRefreshing] = useState(false) + + const handleSwitchDirectory = useCallback( + async (dir?: string) => { + await switchDirectory(dir) + loadSessions() + refreshProject() + }, + [switchDirectory, loadSessions, refreshProject], + ) + + useEffect(() => { + if (client) { + loadSessions() + refreshProject() + } + }, [client]) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + await Promise.all([loadSessions(), refreshProject()]) + setRefreshing(false) + }, []) + + const handleRename = useCallback((session: Session) => { + setRenameText(session.title || "") + setRenaming(session) + }, []) + + const submitRename = useCallback(async () => { + const title = renameText.trim() + if (!title || !renaming || !client) return + await client.session.update(renaming.id, { title }) + setRenaming(null) + setRenameText("") + loadSessions() + }, [renaming, renameText, client, loadSessions]) + + const handleDelete = useCallback( + (session: Session) => { + Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + await deleteSession(session.id) + }, + }, + ]) + }, + [deleteSession], + ) + + const onCreateSession = async () => { + const session = await createSession() + if (session) { + router.push(`/session/${session.id}`) + } + } + + const onCreateInDirectory = async (dir?: string) => { + if (!activeConnection) return + + setIsCreating(true) + + // If a custom directory is specified, use a one-off client for that directory + // so we don't mutate the connection's default project + if (dir && dir.trim()) { + const dirClient = clientForDirectory(dir.trim()) + if (!dirClient) { + setIsCreating(false) + return + } + try { + const session = await dirClient.session.create({}) + addRecentDirectory(dir.trim()) + setIsCreating(false) + setShowNewSession(false) + setCustomDir("") + if (session) { + router.push({ pathname: `/session/[id]`, params: { id: session.id, directory: dir.trim() } }) + } + } catch (error) { + console.error("Failed to create session in directory:", error) + Alert.alert("Error", "Failed to create session in that directory.") + setIsCreating(false) + } + return + } + + const session = await createSession() + setIsCreating(false) + setShowNewSession(false) + setCustomDir("") + if (session) { + router.push(`/session/${session.id}`) + } + } + + const onFabPress = () => { + // Quick create in current project + onCreateSession() + } + + const onFabLongPress = () => { + // Show modal with more options + setCustomDir("") + setShowNewSession(true) + } + + if (!activeConnection) { + return ( + + + No Connection + Add a server connection to get started + router.push("/connection/add")} + > + Add Connection + + + ) + } + + const shortPath = getShortPath(currentProject) + + return ( + + {/* Connection indicator — tap to switch project */} + dirSheetRef.current?.expand()} + onLongPress={() => router.push("/(tabs)/connections")} + activeOpacity={0.7} + > + + + + {activeConnection.name} + + {shortPath && ( + <> + + + {shortPath} + + + )} + + + + + {error && ( + + {error} + + )} + + item.id} + renderItem={({ item }) => ( + handleRename(item)} + onDelete={() => handleDelete(item)} + /> + )} + refreshControl={ + + } + ListEmptyComponent={ + isLoading ? ( + + + + ) : ( + + No sessions yet + + ) + } + contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined} + /> + + {/* FAB to create new session */} + + + + + {/* New Session Info Modal */} + + + setShowNewSession(false)} /> + + + New Session + setShowNewSession(false)}> + + + + + + {/* Current directory */} + Current Directory + + + + {currentProject?.path?.absolute || activeConnection?.directory || "Server default"} + + + + {/* Custom directory input */} + + Or use a different folder + + { + // Expand ~ to server home directory + if (serverHome && text.startsWith("~/")) { + setCustomDir(serverHome + text.slice(1)) + } else if (serverHome && text === "~") { + setCustomDir(serverHome) + } else { + setCustomDir(text) + } + }} + autoCapitalize="none" + autoCorrect={false} + /> + {/* Quick path shortcuts */} + {serverHome && ( + + setCustomDir(serverHome)} + > + ~ + + setCustomDir(serverHome + "/")} + > + ~/ + + + )} + + + + {customDir.trim() ? ( + onCreateInDirectory(customDir)} + disabled={isCreating} + > + {isCreating ? ( + + ) : ( + + Create in {customDir.split("/").filter(Boolean).pop() || customDir} + + )} + + ) : ( + onCreateInDirectory()} + disabled={isCreating} + > + {isCreating ? ( + + ) : ( + + Create Session + + )} + + )} + + + + + + {/* Rename modal */} + + + setRenaming(null)} /> + + Rename Session + + + setRenaming(null)}> + Cancel + + + Save + + + + setRenaming(null)} /> + + + + {/* Directory switcher bottom sheet */} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#ffffff", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + connectionBar: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + connectionBarDark: { + borderBottomColor: "#1a1a1a", + }, + connectionInfo: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + connectionDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + connectionName: { + fontSize: 14, + fontWeight: "600", + color: "#0a0a0a", + }, + connectionUrl: { + fontSize: 12, + color: "#666666", + }, + projectPath: { + fontSize: 13, + color: "#666666", + flex: 1, + }, + errorBar: { + backgroundColor: "#fef2f2", + padding: 12, + borderBottomWidth: 1, + borderBottomColor: "#fecaca", + }, + errorText: { + color: "#dc2626", + fontSize: 14, + }, + sessionItem: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + sessionItemDark: { + borderBottomColor: "#1a1a1a", + }, + sessionContent: { + flex: 1, + }, + sessionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginBottom: 2, + }, + sessionTitle: { + fontSize: 16, + fontWeight: "500", + color: "#0a0a0a", + marginBottom: 4, + }, + textDark: { + color: "#ffffff", + }, + sessionMeta: { + fontSize: 13, + color: "#666666", + }, + sessionMetaRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + sessionDirBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "#f5f5f5", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + sessionDirText: { + fontSize: 11, + color: "#666666", + }, + metaDark: { + color: "#888888", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 32, + backgroundColor: "#ffffff", + }, + emptyTitle: { + fontSize: 20, + fontWeight: "600", + marginTop: 16, + color: "#0a0a0a", + }, + emptySubtitle: { + fontSize: 14, + color: "#666666", + marginTop: 8, + textAlign: "center", + }, + addButton: { + backgroundColor: "#0a0a0a", + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + marginTop: 24, + }, + addButtonDark: { + backgroundColor: "#ffffff", + }, + addButtonText: { + color: "#ffffff", + fontWeight: "600", + }, + addButtonTextDark: { + color: "#0a0a0a", + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 64, + }, + emptyList: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 64, + }, + emptyListText: { + fontSize: 16, + color: "#666666", + }, + emptyContent: { + flex: 1, + }, + fab: { + position: "absolute", + right: 16, + bottom: 16, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: "#0a0a0a", + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + fabDark: { + backgroundColor: "#ffffff", + }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + modalDismiss: { + flex: 1, + }, + modalContent: { + backgroundColor: "#ffffff", + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + paddingBottom: 40, + }, + modalContentDark: { + backgroundColor: "#1a1a1a", + }, + modalHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + modalTitle: { + fontSize: 20, + fontWeight: "600", + color: "#0a0a0a", + }, + modalBody: { + marginBottom: 24, + }, + modalLabel: { + fontSize: 13, + fontWeight: "600", + color: "#666666", + marginBottom: 8, + textTransform: "uppercase", + }, + modalDirBox: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: "#f5f5f5", + padding: 16, + borderRadius: 12, + }, + modalDirBoxDark: { + backgroundColor: "#2a2a2a", + }, + modalDirText: { + fontSize: 15, + color: "#0a0a0a", + flex: 1, + }, + modalInput: { + backgroundColor: "#f5f5f5", + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 15, + color: "#0a0a0a", + }, + modalInputDark: { + backgroundColor: "#2a2a2a", + color: "#ffffff", + }, + pathChips: { + flexDirection: "row", + gap: 8, + marginTop: 8, + }, + pathChip: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: "#e8e5f0", + borderRadius: 16, + }, + pathChipDark: { + backgroundColor: "#2a2040", + }, + pathChipText: { + fontSize: 13, + fontWeight: "600", + color: "#6d28d9", + }, + pathChipTextDark: { + color: "#c4b5fd", + }, + modalHint: { + fontSize: 13, + color: "#666666", + marginTop: 12, + }, + modalActions: { + flexDirection: "row", + gap: 12, + }, + modalButton: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + borderRadius: 12, + }, + modalButtonSecondary: { + backgroundColor: "#f5f5f5", + }, + modalButtonSecondaryDark: { + backgroundColor: "#2a2a2a", + }, + modalButtonPrimary: { + backgroundColor: "#0a0a0a", + }, + modalButtonPrimaryDark: { + backgroundColor: "#ffffff", + }, + modalButtonText: { + fontSize: 15, + fontWeight: "600", + color: "#0a0a0a", + }, + modalButtonTextPrimary: { + fontSize: 15, + fontWeight: "600", + color: "#ffffff", + }, + modalButtonTextPrimaryDark: { + color: "#0a0a0a", + }, + modalButtonFull: { + flex: 0, + width: "100%", + }, + // Rename modal + renameCard: { + backgroundColor: "#ffffff", + borderRadius: 16, + padding: 20, + marginHorizontal: 32, + gap: 16, + }, + renameCardDark: { + backgroundColor: "#1a1a1a", + }, + renameTitle: { + fontSize: 17, + fontWeight: "600", + color: "#0a0a0a", + }, + renameActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: 12, + }, + renameBtn: { + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 10, + }, + renameBtnCancel: { + backgroundColor: "transparent", + }, + renameBtnCancelText: { + fontSize: 15, + fontWeight: "600", + color: "#888888", + }, +}) diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..9bcebb3 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,289 @@ +import { useCallback, useState } from "react" +import { + View, + Text, + ScrollView, + TouchableOpacity, + Switch, + StyleSheet, + useColorScheme, + Linking, + Alert, +} from "react-native" +import { Ionicons } from "@expo/vector-icons" +import { useAuth } from "../../src/stores/auth" +import { useSettings } from "../../src/stores/settings" +import { + categories, + categoryMeta, + setup as setupNotifications, + granted as notificationsGranted, +} from "../../src/lib/notifications" +import type { Category } from "../../src/lib/notifications" + +function SettingRow({ + icon, + label, + description, + isDark, + right, + onPress, +}: { + icon: keyof typeof Ionicons.glyphMap + label: string + description?: string + isDark: boolean + right?: React.ReactNode + onPress?: () => void +}) { + const content = ( + + + + + + {label} + {description && {description}} + + {right} + + ) + + if (onPress) { + return {content} + } + + return content +} + +function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) { + return ( + + {title} + {children} + + ) +} + +export default function SettingsScreen() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { settings, hasBiometrics, updateSettings, lock } = useAuth() + const { notifications, setNotification } = useSettings() + const [osGranted, setOsGranted] = useState(null) + + // Check OS permission state on first toggle attempt + const handleToggle = useCallback( + async (category: Category, enabled: boolean) => { + if (enabled) { + const ok = await setupNotifications() + setOsGranted(ok) + if (!ok) { + Alert.alert( + "Notifications Disabled", + "Enable notifications for OpenCode in your device settings to receive alerts.", + ) + return + } + } + setNotification(category, enabled) + }, + [setNotification], + ) + + // Lazy-check OS permission for status display + if (osGranted === null) { + notificationsGranted().then(setOsGranted) + } + + return ( + + + updateSettings({ requireBiometric: value })} + disabled={!hasBiometrics} + trackColor={{ false: "#767577", true: "#22c55e" }} + /> + } + /> + updateSettings({ requireBiometricForMessages: value })} + disabled={!hasBiometrics || !settings.requireBiometric} + trackColor={{ false: "#767577", true: "#22c55e" }} + /> + } + /> + {settings.requireBiometric && ( + } + /> + )} + + + + {categories.map((category) => { + const meta = categoryMeta[category] + return ( + handleToggle(category, value)} + trackColor={{ false: "#767577", true: "#22c55e" }} + /> + } + /> + ) + })} + {osGranted === false && ( + + + Notifications are disabled at the system level. Enable them in Settings to receive alerts. + + + )} + + + + + Linking.openURL("https://github.com/anomalyco/opencode")} + right={} + /> + Linking.openURL("https://opencode.ai/docs")} + right={} + /> + + + + OpenCode Mobile + + Connect to your AI coding assistant from anywhere + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#f5f5f5", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + content: { + paddingBottom: 32, + }, + section: { + marginTop: 24, + }, + sectionTitle: { + fontSize: 13, + fontWeight: "600", + color: "#666666", + marginLeft: 16, + marginBottom: 8, + textTransform: "uppercase", + }, + sectionTitleDark: { + color: "#888888", + }, + sectionContent: { + backgroundColor: "#ffffff", + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: "#e5e5e5", + }, + sectionContentDark: { + backgroundColor: "#1a1a1a", + borderColor: "#2a2a2a", + }, + settingRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + }, + settingRowDark: { + borderBottomColor: "#2a2a2a", + }, + settingIcon: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: "#f5f5f5", + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + settingIconDark: { + backgroundColor: "#2a2a2a", + }, + settingContent: { + flex: 1, + }, + settingLabel: { + fontSize: 16, + color: "#0a0a0a", + }, + textDark: { + color: "#ffffff", + }, + settingDescription: { + fontSize: 13, + color: "#666666", + marginTop: 2, + }, + metaDark: { + color: "#888888", + }, + footer: { + alignItems: "center", + padding: 32, + }, + footerText: { + fontSize: 13, + color: "#999999", + textAlign: "center", + }, +}) diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..ca1a129 --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef } from "react" +import { Stack, router } from "expo-router" +import { StatusBar } from "expo-status-bar" +import { useColorScheme, View, ActivityIndicator } from "react-native" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { GestureHandlerRootView } from "react-native-gesture-handler" +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet" +import { useAuth } from "../src/stores/auth" +import { useConnections } from "../src/stores/connections" +import { useEvents } from "../src/stores/events" +import { useCatalog } from "../src/stores/catalog" +import { useSettings } from "../src/stores/settings" +import { AuthGate } from "../src/components/AuthGate" +import * as notifications from "../src/lib/notifications" + +const queryClient = new QueryClient() + +export default function RootLayout() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { initialize: initAuth, isLoading: authLoading } = useAuth() + const { loadConnections, isLoading: connectionsLoading, client } = useConnections() + const sseStarted = useRef(false) + + useEffect(() => { + initAuth() + loadConnections() + useSettings.getState().load() + + // Connect notification preferences to the notification module + notifications.configure(() => useSettings.getState().notifications) + + // Navigate to session when user taps a notification + return notifications.onTap((data) => { + router.push(`/session/${data.sessionId}`) + }) + }, []) + + // Connect/disconnect SSE and load catalog when client changes + useEffect(() => { + if (client && !sseStarted.current) { + sseStarted.current = true + useEvents.getState().connect() + useCatalog.getState().load() + } else if (!client && sseStarted.current) { + sseStarted.current = false + useEvents.getState().disconnect() + } + return () => { + if (sseStarted.current) { + sseStarted.current = false + useEvents.getState().disconnect() + } + } + }, [client]) + + const isLoading = authLoading || connectionsLoading + + if (isLoading) { + return ( + + + + ) + } + + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx new file mode 100644 index 0000000..9467ea3 --- /dev/null +++ b/app/connection/[id].tsx @@ -0,0 +1,413 @@ +import { useEffect, useState } from "react" +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + StyleSheet, + useColorScheme, + ActivityIndicator, + Alert, +} from "react-native" +import { router, useLocalSearchParams } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useConnections } from "../../src/stores/connections" +import type { ConnectionType } from "../../src/lib/types" + +const CONNECTION_TYPES: Array<{ + type: ConnectionType + label: string + icon: keyof typeof Ionicons.glyphMap +}> = [ + { type: "local", label: "Local", icon: "wifi" }, + { type: "tunnel", label: "Tunnel", icon: "globe" }, + { type: "cloud", label: "Cloud", icon: "cloud" }, +] + +export default function EditConnectionScreen() { + const { id } = useLocalSearchParams<{ id: string }>() + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { connections, updateConnection, removeConnection, testConnection } = useConnections() + + const connection = connections.find((c) => c.id === id) + + const [type, setType] = useState(connection?.type || "local") + const [name, setName] = useState(connection?.name || "") + const [url, setUrl] = useState(connection?.url || "") + const [directory, setDirectory] = useState(connection?.directory || "") + const [username, setUsername] = useState(connection?.username || "") + const [password, setPassword] = useState("") + const [isTesting, setIsTesting] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + if (connection) { + setType(connection.type) + setName(connection.name) + setUrl(connection.url) + setDirectory(connection.directory || "") + setUsername(connection.username || "") + } + }, [connection]) + + if (!connection) { + return ( + + Connection not found + + ) + } + + const handleTest = async () => { + if (!url.trim()) { + Alert.alert("Error", "Please enter a server URL") + return + } + + setIsTesting(true) + const success = await testConnection( + { + id: connection.id, + name: name || "Test", + type, + url: url.trim(), + directory: directory.trim() || undefined, + username: username.trim() || undefined, + }, + password || undefined, + ) + setIsTesting(false) + + Alert.alert( + success ? "Success" : "Failed", + success ? "Connection successful!" : "Could not connect to the server. Check the URL and credentials.", + ) + } + + const handleSave = async () => { + if (!name.trim()) { + Alert.alert("Error", "Please enter a connection name") + return + } + if (!url.trim()) { + Alert.alert("Error", "Please enter a server URL") + return + } + + setIsSaving(true) + await updateConnection(connection.id, { + name: name.trim(), + type, + url: url.trim(), + directory: directory.trim() || undefined, + username: username.trim() || undefined, + }) + setIsSaving(false) + router.back() + } + + const handleDelete = () => { + Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: async () => { + await removeConnection(connection.id) + router.back() + }, + }, + ]) + } + + return ( + + {/* Connection Type */} + Connection Type + + {CONNECTION_TYPES.map((t) => ( + setType(t.type)} + > + + + {t.label} + + + ))} + + + {/* Name */} + Name + + + {/* URL */} + Server URL + + + {/* Directory */} + Project Directory (Optional) + + + Leave empty to use the server's current directory. Sessions will be created in this folder. + + + {/* Auth */} + Authentication + + Username + + + Password (leave empty to keep existing) + + + {/* Actions */} + + + {isTesting ? ( + + ) : ( + <> + + Test Connection + + )} + + + + {isSaving ? ( + + ) : ( + Save Changes + )} + + + + + Delete Connection + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#ffffff", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + center: { + justifyContent: "center", + alignItems: "center", + }, + content: { + padding: 16, + paddingBottom: 32, + }, + errorText: { + fontSize: 16, + color: "#666666", + }, + label: { + fontSize: 14, + fontWeight: "600", + color: "#0a0a0a", + marginTop: 16, + marginBottom: 8, + }, + labelDark: { + color: "#ffffff", + }, + typeContainer: { + flexDirection: "row", + gap: 8, + }, + typeOption: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + padding: 12, + borderRadius: 8, + backgroundColor: "#f5f5f5", + gap: 6, + }, + typeOptionDark: { + backgroundColor: "#1a1a1a", + }, + typeOptionSelected: { + backgroundColor: "#0a0a0a", + }, + typeOptionSelectedDark: { + backgroundColor: "#ffffff", + }, + typeLabel: { + fontSize: 13, + fontWeight: "500", + color: "#666666", + }, + textDark: { + color: "#ffffff", + }, + typeLabelSelected: { + color: "#ffffff", + }, + typeLabelSelectedDark: { + color: "#0a0a0a", + }, + input: { + backgroundColor: "#f5f5f5", + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + color: "#0a0a0a", + }, + inputDark: { + backgroundColor: "#1a1a1a", + color: "#ffffff", + }, + hint: { + fontSize: 13, + color: "#666666", + marginTop: 6, + }, + hintDark: { + color: "#888888", + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: "#0a0a0a", + marginTop: 32, + marginBottom: 8, + }, + actions: { + marginTop: 32, + gap: 12, + }, + testButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: "#e5e5e5", + }, + testButtonDark: { + borderColor: "#333333", + }, + testButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#0a0a0a", + }, + saveButton: { + alignItems: "center", + justifyContent: "center", + padding: 16, + borderRadius: 12, + backgroundColor: "#0a0a0a", + }, + saveButtonDark: { + backgroundColor: "#ffffff", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + }, + saveButtonTextDark: { + color: "#0a0a0a", + }, + deleteButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + borderRadius: 12, + backgroundColor: "#fef2f2", + }, + deleteButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ef4444", + }, +}) diff --git a/app/connection/add.tsx b/app/connection/add.tsx new file mode 100644 index 0000000..e30bbea --- /dev/null +++ b/app/connection/add.tsx @@ -0,0 +1,548 @@ +import { useState } from "react" +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + StyleSheet, + useColorScheme, + ActivityIndicator, + Alert, +} from "react-native" +import { router } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useConnections } from "../../src/stores/connections" +import type { ConnectionType } from "../../src/lib/types" + +export default function AddConnectionScreen() { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { addConnection, testConnection } = useConnections() + + const [mode, setMode] = useState<"quick" | "advanced">("quick") + const [type, setType] = useState("local") + const [name, setName] = useState("") + const [ip, setIp] = useState("") + const [port, setPort] = useState("4096") + const [url, setUrl] = useState("") + const [directory, setDirectory] = useState("") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [isConnecting, setIsConnecting] = useState(false) + + const buildUrl = () => { + if (mode === "advanced") return url.trim() + if (!ip.trim()) return "" + return `http://${ip.trim()}:${port || "4096"}` + } + + const handleQuickConnect = async () => { + const serverUrl = buildUrl() + if (!serverUrl) { + Alert.alert("Error", "Please enter your computer's IP address") + return + } + + setIsConnecting(true) + + // Test connection first + const success = await testConnection( + { + id: "", + name: name || "My Server", + type: "local", + url: serverUrl, + username: username.trim() || undefined, + }, + password || undefined, + ) + + if (success) { + // Save and go back + await addConnection( + { + name: name.trim() || "My Server", + type: "local", + url: serverUrl, + username: username.trim() || undefined, + }, + password || undefined, + ) + setIsConnecting(false) + router.back() + } else { + setIsConnecting(false) + Alert.alert( + "Connection Failed", + "Could not connect to the server.\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same WiFi network\n3. The IP address is correct", + [{ text: "OK" }], + ) + } + } + + const handleAdvancedSave = async () => { + if (!name.trim()) { + Alert.alert("Error", "Please enter a connection name") + return + } + if (!url.trim()) { + Alert.alert("Error", "Please enter a server URL") + return + } + + setIsConnecting(true) + await addConnection( + { + name: name.trim(), + type, + url: url.trim(), + directory: directory.trim() || undefined, + username: username.trim() || undefined, + }, + password || undefined, + ) + setIsConnecting(false) + router.back() + } + + // Quick connect mode - simplified + if (mode === "quick") { + return ( + + + + Connect to OpenCode + + Enter your computer's IP address to connect + + + + {/* IP Address */} + IP Address + + + : + + + + {/* Optional name */} + Name (optional) + + + {/* Password if needed */} + Password (if set on server) + + + {/* Connect button */} + + {isConnecting ? ( + + ) : ( + <> + + Connect + + )} + + + {/* Help text */} + + How to find your IP: + + On your Mac, run:{"\n"} + ipconfig getifaddr en0 + + + Tailscale examples:{"\n"} + http://100.64.12.34:4096 + {"\n"} + http://my-mac.tailnet.ts.net:4096 + + + Use http:// for local and Tailscale addresses unless TLS is configured, + then use https://. + + + Make sure OpenCode is running:{"\n"} + opencode serve --hostname 0.0.0.0 + + + + {/* Advanced mode link */} + setMode("advanced")}> + + Advanced options (tunnels, cloud, auth) + + + + + ) + } + + // Advanced mode - full options + return ( + + setMode("quick")}> + + Simple mode + + + {/* Connection Type */} + Connection Type + + {[ + { type: "local" as const, label: "Local", icon: "wifi" as const }, + { type: "tunnel" as const, label: "Tunnel", icon: "globe" as const }, + { type: "cloud" as const, label: "Cloud", icon: "cloud" as const }, + ].map((t) => ( + setType(t.type)} + > + + + {t.label} + + + ))} + + + {/* Name */} + Name + + + {/* URL */} + Server URL + + + Local/Tailscale examples: http://100.64.12.34:4096 or{" "} + http://my-mac.tailnet.ts.net:4096. Use https:// + only when TLS is configured. + + + {/* Directory */} + Project Directory (Optional) + + + Leave empty to use the server's current directory, or specify a path to work in a different folder. + + + {/* Auth */} + Authentication + + Username + + + Password + + + {/* Save */} + + {isConnecting ? ( + + ) : ( + Save Connection + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#ffffff", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + content: { + padding: 16, + paddingBottom: 32, + }, + // Quick connect styles + quickHeader: { + alignItems: "center", + paddingVertical: 24, + }, + quickTitle: { + fontSize: 24, + fontWeight: "700", + color: "#0a0a0a", + marginTop: 16, + }, + quickSubtitle: { + fontSize: 15, + color: "#666666", + marginTop: 8, + textAlign: "center", + }, + ipRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + ipInput: { + flex: 1, + }, + ipColon: { + fontSize: 20, + fontWeight: "600", + color: "#0a0a0a", + }, + portInput: { + width: 80, + }, + connectButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + padding: 16, + borderRadius: 12, + backgroundColor: "#0a0a0a", + marginTop: 24, + }, + connectButtonDark: { + backgroundColor: "#ffffff", + }, + connectButtonText: { + fontSize: 18, + fontWeight: "600", + color: "#ffffff", + }, + connectButtonTextDark: { + color: "#0a0a0a", + }, + helpBox: { + backgroundColor: "#f5f5f5", + borderRadius: 12, + padding: 16, + marginTop: 24, + }, + helpBoxDark: { + backgroundColor: "#1a1a1a", + }, + helpTitle: { + fontSize: 14, + fontWeight: "600", + color: "#0a0a0a", + marginBottom: 8, + }, + helpText: { + fontSize: 13, + color: "#666666", + lineHeight: 20, + }, + code: { + fontFamily: "monospace", + backgroundColor: "#e5e5e5", + paddingHorizontal: 4, + borderRadius: 4, + }, + advancedLink: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 4, + paddingVertical: 16, + marginTop: 16, + }, + advancedLinkText: { + fontSize: 14, + color: "#666666", + }, + backToQuick: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingVertical: 8, + }, + backToQuickText: { + fontSize: 14, + color: "#666666", + }, + // Original styles + label: { + fontSize: 14, + fontWeight: "600", + color: "#0a0a0a", + marginTop: 16, + marginBottom: 8, + }, + labelDark: { + color: "#ffffff", + }, + typeContainer: { + flexDirection: "row", + gap: 8, + }, + typeOption: { + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + padding: 12, + borderRadius: 8, + backgroundColor: "#f5f5f5", + gap: 6, + }, + typeOptionDark: { + backgroundColor: "#1a1a1a", + }, + typeOptionSelected: { + backgroundColor: "#0a0a0a", + }, + typeOptionSelectedDark: { + backgroundColor: "#ffffff", + }, + typeLabel: { + fontSize: 13, + fontWeight: "500", + color: "#666666", + }, + textDark: { + color: "#ffffff", + }, + typeLabelSelected: { + color: "#ffffff", + }, + typeLabelSelectedDark: { + color: "#0a0a0a", + }, + hint: { + fontSize: 13, + color: "#666666", + marginTop: 8, + }, + hintDark: { + color: "#888888", + }, + input: { + backgroundColor: "#f5f5f5", + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + color: "#0a0a0a", + }, + inputDark: { + backgroundColor: "#1a1a1a", + color: "#ffffff", + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: "#0a0a0a", + marginTop: 32, + marginBottom: 8, + }, +}) diff --git a/app/session/[id].tsx b/app/session/[id].tsx new file mode 100644 index 0000000..118c388 --- /dev/null +++ b/app/session/[id].tsx @@ -0,0 +1,817 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from "react" +import { + View, + Text, + FlatList, + TextInput, + TouchableOpacity, + StyleSheet, + useColorScheme, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + Alert, +} from "react-native" +import { useLocalSearchParams, Stack, useRouter } from "expo-router" +import { Ionicons } from "@expo/vector-icons" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import * as ImagePicker from "expo-image-picker" +import * as ImageManipulator from "expo-image-manipulator" +import * as Clipboard from "expo-clipboard" +import type BottomSheet from "@gorhom/bottom-sheet" +import { + MessageBubble, + PermissionPrompt, + QuestionPrompt, + StatusIndicator, + SlashPopover, + ModelPicker, + ImageAttachments, + SessionInfo, + type SlashCommand, + type Attachment, +} from "../../src/components/chat" +import { useSessions } from "../../src/stores/sessions" +import { useEvents, refreshPending } from "../../src/stores/events" +import { useConnections } from "../../src/stores/connections" +import { useAuth } from "../../src/stores/auth" +import { useCatalog } from "../../src/stores/catalog" +import { useSpeech } from "../../src/lib/speech" + +// --- Builtin slash commands --- +const BUILTIN_COMMANDS: SlashCommand[] = [ + { + trigger: "new", + title: "New Session", + description: "Start a new session", + icon: "add-circle-outline", + type: "builtin", + }, + { + trigger: "model", + title: "Switch Model", + description: "Choose a different model", + icon: "hardware-chip-outline", + type: "builtin", + }, + { + trigger: "agent", + title: "Switch Agent", + description: "Cycle to next agent", + icon: "person-outline", + type: "builtin", + }, + { + trigger: "compact", + title: "Compact", + description: "Summarize conversation", + icon: "contract-outline", + type: "builtin", + }, + { trigger: "clear", title: "Clear", description: "Clear the session", icon: "trash-outline", type: "builtin" }, +] + +function getShortDir(dir?: string): string | null { + if (!dir) return null + const parts = dir.split("/").filter(Boolean) + return parts[parts.length - 1] || null +} + +export default function SessionScreen() { + const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>() + const router = useRouter() + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + const insets = useSafeAreaInsets() + + const flatListRef = useRef(null) + const modelSheetRef = useRef(null) + const [input, setInput] = useState("") + const [attachments, setAttachments] = useState([]) + const [showInfo, setShowInfo] = useState(false) + + const { + currentSession, + messages, + parts, + isLoading, + loadingMore, + hasMore, + selectSession, + sendMessage, + abortSession, + loadOlderMessages, + } = useSessions() + + // Derive sending state for this specific session + const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id])) + + const { authenticateForMessage } = useAuth() + const { client } = useConnections() + + // Catalog + const catalog = useCatalog() + const agents = Array.isArray(catalog.agents) ? catalog.agents : [] + const serverCommands = Array.isArray(catalog.commands) ? catalog.commands : [] + const providers = Array.isArray(catalog.providers) ? catalog.providers : [] + const agent = catalog.agent || "" + const model = catalog.model + const setModel = catalog.setModel + const cycleAgent = catalog.cycleAgent + + // Permission & question state + const sessionID = currentSession?.id + const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || [] + const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || [] + + const shortDir = getShortDir(currentSession?.directory) + const [showScrollButton, setShowScrollButton] = useState(false) + + // Voice input — transcript appends to the text input on completion + const speech = useSpeech( + useCallback((text: string) => { + setInput((prev) => (prev ? prev + " " + text : text)) + }, []), + ) + + // Slash command state + const slashActive = input.startsWith("/") && !input.includes(" ") + const slashQuery = slashActive ? input.slice(1) : "" + + const allCommands = useMemo(() => { + const custom: SlashCommand[] = serverCommands.map((cmd) => ({ + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + icon: "code-slash-outline", + type: "custom", + })) + return [...custom, ...BUILTIN_COMMANDS] + }, [serverCommands]) + + // Inverted FlatList: data is reversed (newest first) so newest renders at bottom + const messageData = useMemo( + () => + (messages || []) + .map((msg) => ({ + message: msg, + parts: (parts && parts[msg.id]) || [], + })) + .reverse(), + [messages, parts], + ) + + const scrollToBottom = useCallback((animated = true) => { + flatListRef.current?.scrollToOffset({ offset: 0, animated }) + }, []) + + useEffect(() => { + if (!id) return + selectSession(id, directory).then(() => { + // Re-fetch pending permissions/questions from the server to recover from + // missed SSE events or failed optimistic removals + if (client) refreshPending(client, id) + }) + }, [id]) + + // Sync model chip from latest assistant message + useEffect(() => { + if (!messages || messages.length === 0) return + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role === "assistant" && msg.providerID && msg.modelID) { + setModel({ providerID: msg.providerID, modelID: msg.modelID }) + return + } + if (msg.role === "user" && msg.model) { + setModel(msg.model) + return + } + } + }, [currentSession?.id, messages?.length]) + + // Slash command handler + const handleSlashSelect = useCallback( + (cmd: SlashCommand) => { + if (cmd.type === "builtin") { + switch (cmd.trigger) { + case "new": + router.back() + return + case "model": + setInput("") + modelSheetRef.current?.expand() + return + case "agent": + setInput("") + cycleAgent() + return + case "compact": + setInput("") + return + case "clear": + setInput("") + return + } + } + setInput(`/${cmd.trigger} `) + }, + [router, cycleAgent], + ) + + // --- Image picking --- + + // Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes + const MAX_DIMENSION = 1568 // Anthropic recommended max + async function toJpeg(uri: string, width: number, height: number): Promise { + const actions: ImageManipulator.Action[] = [] + if (width > MAX_DIMENSION || height > MAX_DIMENSION) { + const scale = MAX_DIMENSION / Math.max(width, height) + actions.push({ resize: { width: Math.round(width * scale), height: Math.round(height * scale) } }) + } + const result = await ImageManipulator.manipulateAsync(uri, actions, { + format: ImageManipulator.SaveFormat.JPEG, + compress: 0.8, + base64: true, + }) + return { + uri: result.uri, + mime: "image/jpeg", + filename: "image.jpg", + width: result.width, + height: result.height, + base64: result.base64 || undefined, + } + } + + const pickFromLibrary = useCallback(async () => { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + quality: 1, // full quality - we compress in manipulator + }) + if (result.canceled) return + const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height))) + setAttachments((prev) => [...prev, ...items]) + }, []) + + const pickFromCamera = useCallback(async () => { + const perm = await ImagePicker.requestCameraPermissionsAsync() + if (!perm.granted) { + Alert.alert("Permission needed", "Camera access is required to take photos.") + return + } + const result = await ImagePicker.launchCameraAsync({ quality: 1 }) + if (result.canceled) return + const a = result.assets[0] + const item = await toJpeg(a.uri, a.width, a.height) + setAttachments((prev) => [...prev, item]) + }, []) + + const pasteFromClipboard = useCallback(async () => { + // Try image first + const hasImage = await Clipboard.hasImageAsync() + if (hasImage) { + const img = await Clipboard.getImageAsync({ format: "png" }) + if (img?.data) { + const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}` + setAttachments((prev) => [ + ...prev, + { + uri, + mime: "image/png", + filename: "clipboard.png", + width: img.size.width, + height: img.size.height, + }, + ]) + return + } + } + // Fall back to text + const hasText = await Clipboard.hasStringAsync() + if (hasText) { + const text = await Clipboard.getStringAsync() + if (text) { + setInput((prev) => prev + text) + return + } + } + Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.") + }, []) + + const removeAttachment = useCallback((index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)) + }, []) + + // --- Send --- + const handleSend = async () => { + if (!input.trim() && attachments.length === 0) return + const authenticated = await authenticateForMessage() + if (!authenticated) return + + const text = input.trim() + const files = [...attachments] + setInput("") + setAttachments([]) + + // Server slash commands (no attachments for commands) + if (text.startsWith("/") && files.length === 0) { + const [cmdName, ...args] = text.split(" ") + const name = cmdName.slice(1) + const match = serverCommands.find((c) => c.name === name) + if (match && client && currentSession) { + client.session + .command(currentSession.id, { + command: name, + arguments: args.join(" "), + agent, + model: model ? `${model.providerID}/${model.modelID}` : undefined, + }) + .catch((err) => console.error("Command failed:", err)) + return + } + } + + // Messages are queued server-side when the session is busy. + // No need to abort - just send and it will be processed after current response. + await sendMessage(text, model || undefined, agent || undefined, files) + } + + // In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom. + const handleScroll = useCallback((event: any) => { + const { contentOffset } = event.nativeEvent + setShowScrollButton(contentOffset.y > 200) + }, []) + + // Debounce: onEndReached can fire multiple times during a single scroll gesture + const loadingTriggered = useRef(false) + const handleLoadMore = useCallback(() => { + if (hasMore && !loadingMore && !loadingTriggered.current) { + loadingTriggered.current = true + loadOlderMessages() + } + }, [hasMore, loadingMore, loadOlderMessages]) + + // Reset trigger when loading finishes + useEffect(() => { + if (!loadingMore) loadingTriggered.current = false + }, [loadingMore]) + + const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => { + if (!client || !sessionID) return + // Snapshot for rollback + const snapshot = useEvents.getState().permissions[sessionID] || [] + // Optimistically remove from UI + useEvents.setState((state) => ({ + permissions: { + ...state.permissions, + [sessionID]: snapshot.filter((p) => p.id !== requestID), + }, + })) + try { + await client.permission.reply(requestID, reply) + } catch (err) { + console.error("Permission reply failed:", err) + // Restore the prompt so the user can retry + useEvents.setState((state) => ({ + permissions: { ...state.permissions, [sessionID]: snapshot }, + })) + Alert.alert("Reply Failed", "Could not send your response. Please try again.") + } + } + + const handleQuestionReply = async (requestID: string, answers: string[][]) => { + if (!client || !sessionID) return + const snapshot = useEvents.getState().questions[sessionID] || [] + useEvents.setState((state) => ({ + questions: { + ...state.questions, + [sessionID]: snapshot.filter((q) => q.id !== requestID), + }, + })) + try { + await client.question.reply(requestID, answers) + } catch (err) { + console.error("Question reply failed:", err) + useEvents.setState((state) => ({ + questions: { ...state.questions, [sessionID]: snapshot }, + })) + Alert.alert("Reply Failed", "Could not send your response. Please try again.") + } + } + + const handleQuestionReject = async (requestID: string) => { + if (!client || !sessionID) return + const snapshot = useEvents.getState().questions[sessionID] || [] + useEvents.setState((state) => ({ + questions: { + ...state.questions, + [sessionID]: snapshot.filter((q) => q.id !== requestID), + }, + })) + try { + await client.question.reject(requestID) + } catch (err) { + console.error("Question reject failed:", err) + useEvents.setState((state) => ({ + questions: { ...state.questions, [sessionID]: snapshot }, + })) + Alert.alert("Reject Failed", "Could not send your response. Please try again.") + } + } + + const handleModelSelect = useCallback( + (providerID: string, modelID: string) => { + setModel({ providerID, modelID }) + }, + [setModel], + ) + + // Current agent display + const currentAgent = agents.find((a) => a.name === agent) + const agentColor = currentAgent?.color || "#8b5cf6" + const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default" + + return ( + <> + ( + + {shortDir && ( + + + {shortDir} + + )} + setShowInfo((v) => !v)} hitSlop={8}> + + + + ), + }} + /> + + + {/* Session info pulldown */} + { + if (hasMore && !loadingMore) loadOlderMessages() + }} + onScrollToTop={() => { + flatListRef.current?.scrollToEnd({ animated: true }) + }} + onClose={() => setShowInfo(false)} + /> + + {isLoading ? ( + + + + ) : ( + + item.message.id} + renderItem={({ item }) => } + contentContainerStyle={s.messageList} + onScroll={handleScroll} + scrollEventThrottle={100} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + // Prevent jump when older messages are prepended + maintainVisibleContentPosition={{ minIndexForVisible: 0 }} + ListFooterComponent={ + loadingMore ? ( + + + Loading older messages... + + ) : null + } + ListEmptyComponent={ + + + Start a conversation + Type / for commands + + } + /> + {showScrollButton && ( + scrollToBottom(true)}> + + + )} + + )} + + {/* Status */} + {currentSession && } + + {/* Permissions */} + {permissions.map((perm) => ( + handlePermissionReply(perm.id, reply)} + /> + ))} + + {/* Questions */} + {questions.map((q) => ( + handleQuestionReply(q.id, answers)} + onReject={() => handleQuestionReject(q.id)} + /> + ))} + + {/* Slash popover */} + {slashActive && ( + + )} + + {/* Agent/model toolbar */} + + cycleAgent()} + onLongPress={() => cycleAgent(-1)} + > + + {agent || "build"} + + + + modelSheetRef.current?.expand()} + > + + + {modelLabel} + + + + + {/* Attachment preview */} + + + {/* Input */} + + + {/* Attach button */} + + + + + {/* Clipboard paste button */} + + + + + + {/* Stop button: only when busy and no input */} + {isSending && !input.trim() && attachments.length === 0 && !speech.listening && ( + + + + )} + {/* Mic button: when no input, not sending, and not listening */} + {!isSending && !input.trim() && attachments.length === 0 && !speech.listening && ( + + + + )} + {/* Listening indicator: tap to stop */} + {speech.listening && ( + + + + )} + {/* Send button: when there's input */} + {!speech.listening && (input.trim() || attachments.length > 0) && ( + + + + )} + + + + + {/* Model picker bottom sheet */} + + + ) +} + +const s = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#ffffff" }, + containerDark: { backgroundColor: "#0a0a0a" }, + loading: { flex: 1, justifyContent: "center", alignItems: "center" }, + listWrap: { flex: 1, position: "relative" }, + + // Messages + messageList: { padding: 16, paddingBottom: 8 }, + + // Scroll button + scrollBtn: { + position: "absolute", + bottom: 16, + right: 16, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: "#ffffff", + justifyContent: "center", + alignItems: "center", + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 4, + }, + scrollBtnDark: { backgroundColor: "#2a2a2a" }, + + // Loading more (appears at top in inverted list = ListFooterComponent) + loadingMore: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: 8, + paddingVertical: 16, + }, + loadingMoreText: { fontSize: 13, color: "#999999" }, + + // Empty (inverted list flips content, so use transform to un-flip) + emptyInverted: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 64, + transform: [{ scaleY: -1 }], + }, + + // Empty + empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 }, + emptyText: { fontSize: 16, color: "#999999", marginTop: 12 }, + emptyHint: { fontSize: 13, color: "#bbbbbb", marginTop: 4 }, + metaDark: { color: "#666666" }, + textWhite: { color: "#ffffff" }, + + // Toolbar + toolbar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 12, + paddingVertical: 6, + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + backgroundColor: "#ffffff", + }, + toolbarDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" }, + agentChip: { + flexDirection: "row", + alignItems: "center", + gap: 5, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + }, + agentDot: { width: 8, height: 8, borderRadius: 4 }, + agentLabel: { fontSize: 12, fontWeight: "600", color: "#0a0a0a" }, + modelChip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "#f5f5f5", + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + }, + modelChipDark: { backgroundColor: "#1a1a1a" }, + modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 }, + + // Input + inputContainer: { + padding: 12, + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + backgroundColor: "#ffffff", + }, + inputContainerDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" }, + inputRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + attachBtn: { + width: 36, + height: 40, + justifyContent: "center", + alignItems: "center", + }, + input: { + flex: 1, + backgroundColor: "#f5f5f5", + borderRadius: 20, + paddingHorizontal: 16, + paddingVertical: 10, + fontSize: 16, + maxHeight: 120, + color: "#0a0a0a", + }, + inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff" }, + inputListening: { borderWidth: 1, borderColor: "#ef4444" }, + sendBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#0a0a0a", + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + sendBtnDisabled: { backgroundColor: "#cccccc" }, + micBtn: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + micBtnActive: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#ef4444", + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + stopBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#ef4444", + justifyContent: "center", + alignItems: "center", + marginLeft: 8, + }, + + // Header + headerRight: { flexDirection: "row", alignItems: "center", gap: 8 }, + dirBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "#f5f5f5", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + dirBadgeDark: { backgroundColor: "#1a1a1a" }, + dirText: { fontSize: 12, color: "#666666", fontWeight: "500" }, + dirTextDark: { color: "#888888" }, +}) diff --git a/assets/adaptive-icon.json b/assets/adaptive-icon.json new file mode 100644 index 0000000..20de46b --- /dev/null +++ b/assets/adaptive-icon.json @@ -0,0 +1 @@ +{"placeholder": true} diff --git a/assets/favicon.json b/assets/favicon.json new file mode 100644 index 0000000..20de46b --- /dev/null +++ b/assets/favicon.json @@ -0,0 +1 @@ +{"placeholder": true} diff --git a/assets/icon.json b/assets/icon.json new file mode 100644 index 0000000..20de46b --- /dev/null +++ b/assets/icon.json @@ -0,0 +1 @@ +{"placeholder": true} diff --git a/assets/splash-icon.json b/assets/splash-icon.json new file mode 100644 index 0000000..20de46b --- /dev/null +++ b/assets/splash-icon.json @@ -0,0 +1 @@ +{"placeholder": true} diff --git a/package.json b/package.json new file mode 100644 index 0000000..25aff32 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "@opencode-ai/mobile", + "version": "0.0.1", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "start:clear": "expo start --clear", + "ios": "expo run:ios", + "android": "expo run:android", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "5.2.8", + "@react-navigation/native": "^7.0.14", + "@tanstack/react-query": "^5.62.0", + "expo": "^54.0.0", + "expo-clipboard": "8.0.8", + "expo-device": "~8.0.10", + "expo-image-manipulator": "~14.0.8", + "expo-image-picker": "17.0.10", + "expo-linking": "~8.0.11", + "expo-local-authentication": "~17.0.8", + "expo-notifications": "~0.32.16", + "expo-router": "~6.0.22", + "expo-secure-store": "~15.0.8", + "expo-speech-recognition": "3.0.1", + "expo-status-bar": "~3.0.9", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-marked": "8.0.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-svg": "15.15.1", + "react-native-worklets": "0.5.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "~19.1.10", + "typescript": "~5.9.2" + }, + "expo": { + "install": { + "exclude": [] + } + } +} diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx new file mode 100644 index 0000000..db7e7a5 --- /dev/null +++ b/src/components/AuthGate.tsx @@ -0,0 +1,110 @@ +import { useEffect, type ReactNode } from "react" +import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import { useAuth } from "../stores/auth" + +interface Props { + children: ReactNode +} + +export function AuthGate({ children }: Props) { + const colorScheme = useColorScheme() + const isDark = colorScheme === "dark" + + const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth() + + // If biometric not required, or no biometrics available, show children + if (!settings.requireBiometric || !hasBiometrics) { + return <>{children} + } + + // If authenticated, show children + if (isAuthenticated) { + return <>{children} + } + + // Show auth screen + const iconName = + biometricType === 1 // FINGERPRINT + ? "finger-print" + : biometricType === 2 // FACIAL_RECOGNITION + ? "scan" + : "lock-closed" + + return ( + + + + OpenCode Locked + Authenticate to access your sessions + + {error && {error}} + + + + Unlock + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#ffffff", + justifyContent: "center", + alignItems: "center", + }, + containerDark: { + backgroundColor: "#0a0a0a", + }, + content: { + alignItems: "center", + padding: 32, + }, + title: { + fontSize: 24, + fontWeight: "600", + marginTop: 24, + color: "#0a0a0a", + }, + textDark: { + color: "#ffffff", + }, + subtitle: { + fontSize: 16, + color: "#666666", + marginTop: 8, + textAlign: "center", + }, + subtitleDark: { + color: "#888888", + }, + error: { + color: "#ef4444", + marginTop: 16, + fontSize: 14, + }, + button: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#0a0a0a", + paddingHorizontal: 32, + paddingVertical: 16, + borderRadius: 12, + marginTop: 32, + gap: 12, + }, + buttonDark: { + backgroundColor: "#ffffff", + }, + buttonText: { + color: "#ffffff", + fontSize: 18, + fontWeight: "600", + }, + buttonTextDark: { + color: "#0a0a0a", + }, +}) diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx new file mode 100644 index 0000000..7209a99 --- /dev/null +++ b/src/components/chat/DiffView.tsx @@ -0,0 +1,147 @@ +import { View, Text, StyleSheet, Platform } from "react-native" + +const mono = Platform.OS === "ios" ? "Menlo" : "monospace" + +interface Props { + before: string + after: string + isDark: boolean +} + +interface DiffLine { + type: "add" | "remove" | "context" + text: string +} + +function computeDiff(before: string, after: string): DiffLine[] { + const a = before.split("\n") + const b = after.split("\n") + const lines: DiffLine[] = [] + + // Simple LCS-based diff + const m = a.length + const n = b.length + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + + // Backtrack + const result: DiffLine[] = [] + let i = m + let j = n + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + result.push({ type: "context", text: a[i - 1] }) + i-- + j-- + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + result.push({ type: "add", text: b[j - 1] }) + j-- + } else { + result.push({ type: "remove", text: a[i - 1] }) + i-- + } + } + + result.reverse() + + // Collapse long context runs (show max 3 context lines between changes) + const collapsed: DiffLine[] = [] + let contextRun = 0 + for (const line of result) { + if (line.type === "context") { + contextRun++ + if (contextRun <= 3) { + collapsed.push(line) + } else if (contextRun === 4) { + collapsed.push({ type: "context", text: "..." }) + } + } else { + contextRun = 0 + collapsed.push(line) + } + } + + return collapsed +} + +export function DiffView({ before, after, isDark }: Props) { + const lines = computeDiff(before, after) + + if (lines.length === 0) return null + + return ( + + {lines.map((line, idx) => ( + + + {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} + + + {line.text} + + + ))} + + ) +} + +const s = StyleSheet.create({ + container: { + borderRadius: 6, + overflow: "hidden", + backgroundColor: "#f8f8f8", + marginTop: 6, + }, + containerDark: { backgroundColor: "#1a1a1a" }, + + line: { + flexDirection: "row", + paddingHorizontal: 8, + paddingVertical: 1, + }, + add: { backgroundColor: "#dcfce7" }, + addDark: { backgroundColor: "#052e16" }, + remove: { backgroundColor: "#fee2e2" }, + removeDark: { backgroundColor: "#2a0a0a" }, + + prefix: { + width: 16, + fontSize: 12, + fontFamily: mono, + color: "#999999", + lineHeight: 20, + }, + prefixDark: { color: "#666666" }, + + text: { + flex: 1, + fontSize: 12, + fontFamily: mono, + color: "#0a0a0a", + lineHeight: 20, + }, + textDark: { color: "#e5e5e5" }, + addText: { color: "#16a34a" }, + removeText: { color: "#dc2626" }, +}) diff --git a/src/components/chat/DirectorySwitcher.tsx b/src/components/chat/DirectorySwitcher.tsx new file mode 100644 index 0000000..82de552 --- /dev/null +++ b/src/components/chat/DirectorySwitcher.tsx @@ -0,0 +1,246 @@ +import { useState, useCallback, useMemo } from "react" +import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet" + +interface Props { + sheetRef: React.RefObject + current?: string + recents: string[] + serverHome: string | null + isDark: boolean + onSwitch: (directory?: string) => void +} + +export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch }: Props) { + const [custom, setCustom] = useState("") + + const handleSelect = useCallback( + (dir?: string) => { + onSwitch(dir) + setCustom("") + sheetRef.current?.close() + }, + [onSwitch, sheetRef], + ) + + const handleCustomSubmit = useCallback(() => { + const dir = custom.trim() + if (!dir) return + handleSelect(dir) + }, [custom, handleSelect]) + + // Build list: server default + recents (excluding current) + const items = useMemo(() => { + const list: Array<{ label: string; dir?: string; active: boolean }> = [ + { label: "Server Default", dir: undefined, active: !current }, + ] + for (const dir of recents) { + if (dir === current) continue + const short = dir.split("/").filter(Boolean).pop() || dir + list.push({ label: short, dir, active: false }) + } + return list + }, [recents, current]) + + const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null + + return ( + ( + + )} + onChange={(idx) => { + if (idx === -1) setCustom("") + }} + > + + Switch Project + {shortCurrent && ( + + + + {shortCurrent} + + + )} + + + {/* Custom directory input */} + + { + if (serverHome && text === "~") setCustom(serverHome) + else if (serverHome && text.startsWith("~/")) setCustom(serverHome + text.slice(1)) + else setCustom(text) + }} + onSubmitEditing={handleCustomSubmit} + returnKeyType="go" + autoCapitalize="none" + autoCorrect={false} + /> + {custom.trim() && ( + + + + )} + + + {/* Quick path chips */} + {serverHome && ( + + setCustom(serverHome)}> + ~ + + setCustom(serverHome + "/")}> + ~/ + + + )} + + {/* Recent directories */} + item.dir || `default-${i}`} + renderItem={({ item }: { item: (typeof items)[number] }) => ( + handleSelect(item.dir)} + > + + + + + + {item.label} + + {item.dir && ( + + {item.dir} + + )} + {!item.dir && Uses server's working directory} + + {item.active && } + + )} + contentContainerStyle={s.list} + ListHeaderComponent={ + items.length > 1 ? Recent Projects : null + } + /> + + ) +} + +const s = StyleSheet.create({ + sheet: { backgroundColor: "#ffffff" }, + sheetDark: { backgroundColor: "#1a1a1a" }, + header: { paddingHorizontal: 16, paddingBottom: 8, gap: 6 }, + title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" }, + white: { color: "#ffffff" }, + current: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + currentText: { + fontSize: 13, + color: "#8b5cf6", + fontWeight: "500", + }, + inputWrap: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingBottom: 8, + gap: 8, + }, + chips: { + flexDirection: "row", + gap: 8, + paddingHorizontal: 16, + paddingBottom: 12, + }, + chip: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: "#e8e5f0", + borderRadius: 16, + }, + chipDark: { + backgroundColor: "#2a2040", + }, + chipText: { + fontSize: 13, + fontWeight: "600", + color: "#6d28d9", + }, + chipTextDark: { + color: "#c4b5fd", + }, + input: { + flex: 1, + backgroundColor: "#f5f5f5", + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15, + color: "#0a0a0a", + }, + inputDark: { backgroundColor: "#2a2a2a", color: "#ffffff" }, + goBtn: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "#0a0a0a", + justifyContent: "center", + alignItems: "center", + }, + goBtnDark: { backgroundColor: "#ffffff" }, + list: { paddingBottom: 40 }, + section: { + fontSize: 12, + fontWeight: "700", + color: "#999999", + textTransform: "uppercase", + letterSpacing: 0.5, + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 8, + }, + dimDark: { color: "#666666" }, + row: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e5e5e5", + gap: 12, + }, + rowDark: { borderBottomColor: "#2a2a2a" }, + rowActive: { backgroundColor: "#f5f3ff" }, + rowIcon: { width: 28, alignItems: "center" }, + rowContent: { flex: 1 }, + rowLabel: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" }, + rowLabelActive: { color: "#8b5cf6" }, + rowPath: { fontSize: 12, color: "#999999", marginTop: 1 }, +}) diff --git a/src/components/chat/ImageAttachments.tsx b/src/components/chat/ImageAttachments.tsx new file mode 100644 index 0000000..9356aa3 --- /dev/null +++ b/src/components/chat/ImageAttachments.tsx @@ -0,0 +1,82 @@ +import { View, Text, Image, TouchableOpacity, StyleSheet, ScrollView } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +export interface Attachment { + uri: string + mime: string + filename?: string + width?: number + height?: number + base64?: string +} + +interface Props { + attachments: Attachment[] + isDark: boolean + onRemove: (index: number) => void +} + +export function ImageAttachments({ attachments, isDark, onRemove }: Props) { + if (attachments.length === 0) return null + + return ( + + + {attachments.map((att, idx) => ( + + + onRemove(idx)}> + + + {att.filename && ( + + {att.filename} + + )} + + ))} + + + ) +} + +const s = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + backgroundColor: "#ffffff", + }, + containerDark: { backgroundColor: "#0a0a0a", borderTopColor: "#1a1a1a" }, + scroll: { gap: 8 }, + thumb: { position: "relative" }, + image: { + width: 72, + height: 72, + borderRadius: 8, + backgroundColor: "#f0f0f0", + }, + remove: { + position: "absolute", + top: -6, + right: -6, + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: "#0a0a0a", + justifyContent: "center", + alignItems: "center", + borderWidth: 2, + borderColor: "#ffffff", + }, + removeDark: { backgroundColor: "#ef4444", borderColor: "#0a0a0a" }, + label: { + fontSize: 10, + color: "#666666", + marginTop: 2, + maxWidth: 72, + textAlign: "center", + }, + labelDark: { color: "#888888" }, +}) diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..249007e --- /dev/null +++ b/src/components/chat/MessageBubble.tsx @@ -0,0 +1,162 @@ +import { memo } from "react" +import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import { Markdown } from "../markdown" +import { ToolCallCard } from "./ToolCallCard" +import { ReasoningBlock } from "./ReasoningBlock" +import type { Message, Part } from "../../lib/sdk" + +const SCREEN_WIDTH = Dimensions.get("window").width + +function isImageMime(mime?: string): boolean { + return !!mime && mime.startsWith("image/") +} + +interface Props { + message: Message + parts: Part[] + isDark: boolean +} + +// TODO: Replace with streamdown-rn once React 19 types PR lands - it has +// built-in block-level memoization that eliminates re-renders for stable blocks +export const MessageBubble = memo( + function MessageBubble({ message, parts, isDark }: Props) { + const isUser = message.role === "user" + + const textParts = parts.filter((p) => p.type === "text") + const reasoningParts = parts.filter((p) => p.type === "reasoning") + const toolParts = parts.filter((p) => p.type === "tool") + const fileParts = parts.filter((p) => p.type === "file" && isImageMime(p.mime)) + const text = textParts.map((p) => p.text).join("\n") || "" + const reasoning = reasoningParts.map((p) => p.text).join("\n") || "" + + return ( + + {/* Role indicator */} + + + {isUser ? "You" : "Assistant"} + {message.model && {message.model.modelID}} + {!isUser && message.modelID && {message.modelID}} + + + {/* Image attachments */} + {fileParts.length > 0 && ( + + {fileParts.map((fp) => ( + + + {fp.filename && ( + + {fp.filename} + + )} + + ))} + + )} + + {/* Reasoning (collapsible) */} + {reasoning.length > 0 && } + + {/* Message text */} + {text.length > 0 && + (isUser ? ( + + {text} + + ) : ( + + {text} + + ))} + + {/* Tool calls */} + {toolParts.map((tool) => ( + + ))} + + {/* Tokens/cost for assistant messages */} + {!isUser && message.tokens && ( + + {message.tokens.input + message.tokens.output} tokens + {message.cost ? ` · $${message.cost.toFixed(4)}` : ""} + + )} + + ) + }, + (prev, next) => { + // Only re-render if message content actually changed + // This prevents completed messages from re-rendering during streaming + if (prev.message.id !== next.message.id) return false + if (prev.isDark !== next.isDark) return false + if (prev.parts.length !== next.parts.length) return false + // Compare the last part's text content - this is what changes during streaming + const prevLast = prev.parts[prev.parts.length - 1] + const nextLast = next.parts[next.parts.length - 1] + if (!prevLast && !nextLast) return true + if (!prevLast || !nextLast) return false + return prevLast.type === nextLast.type && prevLast.text === nextLast.text + }, +) + +const s = StyleSheet.create({ + bubble: { marginBottom: 16, padding: 12, borderRadius: 12, maxWidth: "100%" }, + user: { backgroundColor: "#f5f5f5", marginLeft: 32 }, + userDark: { backgroundColor: "#1a1a1a" }, + assistant: { backgroundColor: "#f0f0ff" }, + assistantDark: { backgroundColor: "#1a1a2e" }, + + header: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 }, + role: { fontSize: 13, fontWeight: "600", color: "#666666" }, + roleUser: { color: "#0a0a0a" }, + textWhite: { color: "#ffffff" }, + + modelTag: { + fontSize: 11, + color: "#999999", + backgroundColor: "#e5e5e5", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + overflow: "hidden", + }, + modelTagDark: { backgroundColor: "#2a2a2a", color: "#888888" }, + + messageText: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" }, + markdownWrap: { marginHorizontal: -4 }, + + tokens: { fontSize: 11, color: "#999999", marginTop: 8 }, + tokensDark: { color: "#666666" }, + + // Images + imageScroll: { marginBottom: 8 }, + imageRow: { gap: 8 }, + imageWrap: { alignItems: "center" }, + attachedImage: { + width: Math.min(200, SCREEN_WIDTH * 0.5), + height: Math.min(200, SCREEN_WIDTH * 0.5), + borderRadius: 8, + backgroundColor: "#e5e5e5", + }, + imageLabel: { fontSize: 10, color: "#666666", marginTop: 2, maxWidth: 200 }, + imageLabelDark: { color: "#888888" }, +}) diff --git a/src/components/chat/ModelPicker.tsx b/src/components/chat/ModelPicker.tsx new file mode 100644 index 0000000..b5113b5 --- /dev/null +++ b/src/components/chat/ModelPicker.tsx @@ -0,0 +1,186 @@ +import { useState, useCallback, useMemo, useRef } from "react" +import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet" + +interface ModelItem { + providerID: string + providerName: string + modelID: string + modelName: string +} + +interface Provider { + id: string + name: string + models: Array<{ id: string; name: string }> +} + +interface Props { + providers: Provider[] + selected: { providerID: string; modelID: string } | null + isDark: boolean + onSelect: (providerID: string, modelID: string) => void + sheetRef: React.RefObject +} + +export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) { + const [search, setSearch] = useState("") + + const sections = useMemo(() => { + const list = Array.isArray(providers) ? providers : [] + const q = search.toLowerCase() + const result = list + .map((p) => { + const models = (p.models || []) + .filter( + (m) => + !q || + m.id.toLowerCase().includes(q) || + m.name.toLowerCase().includes(q) || + p.name.toLowerCase().includes(q), + ) + .map((m) => ({ + providerID: p.id, + providerName: p.name || p.id, + modelID: m.id, + modelName: m.name || m.id, + })) + // Sort selected model to top within its group + if (selected) { + models.sort((a, b) => { + const aActive = a.providerID === selected.providerID && a.modelID === selected.modelID + const bActive = b.providerID === selected.providerID && b.modelID === selected.modelID + return aActive === bActive ? 0 : aActive ? -1 : 1 + }) + } + return { title: p.name || p.id, data: models } + }) + .filter((s) => s.data.length > 0) + // Sort the section containing the selected model to the top + if (selected) { + result.sort((a, b) => { + const aHas = a.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID) + const bHas = b.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID) + return aHas === bHas ? 0 : aHas ? -1 : 1 + }) + } + return result + }, [providers, search, selected]) + + const handleSelect = useCallback( + (providerID: string, modelID: string) => { + onSelect(providerID, modelID) + setSearch("") + sheetRef.current?.close() + }, + [onSelect, sheetRef], + ) + + return ( + ( + + )} + onChange={(idx) => { + if (idx === -1) setSearch("") + }} + > + + Select Model + + + `${item.providerID}/${item.modelID}`} + renderSectionHeader={({ section }: { section: { title: string } }) => ( + + {section.title} + + )} + renderItem={({ item }: { item: ModelItem }) => { + const active = selected?.providerID === item.providerID && selected?.modelID === item.modelID + return ( + handleSelect(item.providerID, item.modelID)} + > + + + {item.modelName || item.modelID} + + {item.providerName || item.providerID} + + {active && } + + ) + }} + contentContainerStyle={s.content} + stickySectionHeadersEnabled + /> + + ) +} + +const s = StyleSheet.create({ + sheet: { backgroundColor: "#ffffff" }, + sheetDark: { backgroundColor: "#1a1a1a" }, + header: { paddingHorizontal: 16, paddingBottom: 12, gap: 10 }, + title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" }, + textWhite: { color: "#ffffff" }, + search: { + backgroundColor: "#f5f5f5", + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15, + color: "#0a0a0a", + }, + searchDark: { backgroundColor: "#2a2a2a", color: "#ffffff" }, + content: { paddingBottom: 40 }, + sectionHeader: { + backgroundColor: "#f5f5f5", + paddingHorizontal: 16, + paddingVertical: 8, + }, + sectionHeaderDark: { backgroundColor: "#111111" }, + sectionTitle: { + fontSize: 12, + fontWeight: "700", + color: "#999999", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + metaDark: { color: "#666666" }, + row: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e5e5e5", + }, + rowDark: { borderBottomColor: "#2a2a2a" }, + rowSelected: { backgroundColor: "#f5f3ff" }, + rowSelectedDark: { backgroundColor: "#1f1a2e" }, + rowText: { flex: 1 }, + rowName: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" }, + rowProvider: { fontSize: 12, color: "#999999", marginTop: 1 }, +}) diff --git a/src/components/chat/PermissionPrompt.tsx b/src/components/chat/PermissionPrompt.tsx new file mode 100644 index 0000000..ed02c5b --- /dev/null +++ b/src/components/chat/PermissionPrompt.tsx @@ -0,0 +1,61 @@ +import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +interface Props { + permission: { id: string; permission: string; patterns: string[] } + isDark: boolean + onReply: (reply: "once" | "always" | "reject") => void +} + +export function PermissionPrompt({ permission, isDark, onReply }: Props) { + return ( + + + + Permission Required + + + {permission.permission}: {permission.patterns.join(", ")} + + + onReply("reject")}> + Deny + + onReply("always")}> + Always + + onReply("once")}> + Allow + + + + ) +} + +const s = StyleSheet.create({ + card: { + margin: 12, + padding: 16, + backgroundColor: "#fffbeb", + borderRadius: 12, + borderWidth: 1, + borderColor: "#fef3c7", + }, + cardDark: { backgroundColor: "#1a1800", borderColor: "#333300" }, + header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, + title: { fontSize: 15, fontWeight: "600", color: "#92400e" }, + textWhite: { color: "#ffffff" }, + type: { fontSize: 13, color: "#78350f", marginBottom: 12 }, + typeDark: { color: "#d4a574" }, + actions: { flexDirection: "row", gap: 8 }, + btn: { flex: 1, paddingVertical: 10, borderRadius: 8, alignItems: "center" }, + deny: { backgroundColor: "#fef2f2" }, + denyText: { color: "#dc2626", fontWeight: "600", fontSize: 14 }, + always: { backgroundColor: "#f5f5f5" }, + alwaysDark: { backgroundColor: "#2a2a2a" }, + alwaysText: { color: "#0a0a0a", fontWeight: "600", fontSize: 14 }, + allow: { backgroundColor: "#0a0a0a" }, + allowDark: { backgroundColor: "#ffffff" }, + allowText: { color: "#ffffff", fontWeight: "600", fontSize: 14 }, + allowTextDark: { color: "#0a0a0a" }, +}) diff --git a/src/components/chat/QuestionPrompt.tsx b/src/components/chat/QuestionPrompt.tsx new file mode 100644 index 0000000..e5c1b67 --- /dev/null +++ b/src/components/chat/QuestionPrompt.tsx @@ -0,0 +1,190 @@ +import { useState } from "react" +import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +interface QuestionOption { + label: string + description: string +} + +interface Question { + question: string + header: string + options: QuestionOption[] + multiple?: boolean + custom?: boolean +} + +interface Props { + request: { + id: string + questions: Question[] + } + isDark: boolean + onReply: (answers: string[][]) => void + onReject: () => void +} + +export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { + const [answers, setAnswers] = useState(request.questions.map(() => [])) + const [custom, setCustom] = useState("") + const [showCustom, setShowCustom] = useState(false) + const [current, setCurrent] = useState(0) + + const q = request.questions[current] + if (!q) return null + + const toggleOption = (label: string) => { + setAnswers((prev) => { + const copy = [...prev] + const selected = copy[current] || [] + if (q.multiple) { + copy[current] = selected.includes(label) ? selected.filter((a) => a !== label) : [...selected, label] + } else { + copy[current] = [label] + if (request.questions.length === 1) { + setTimeout(() => onReply(copy), 100) + } + } + return copy + }) + } + + const submitCustom = () => { + if (!custom.trim()) return + const copy = [...answers] + copy[current] = [custom.trim()] + setAnswers(copy) + setCustom("") + setShowCustom(false) + if (request.questions.length === 1) { + onReply(copy) + } + } + + return ( + + + + {q.header || "Question"} + + {q.question} + + + {q.options.map((opt) => { + const selected = (answers[current] || []).includes(opt.label) + return ( + toggleOption(opt.label)} + > + {opt.label} + {opt.description ? {opt.description} : null} + + ) + })} + + {q.custom !== false && + (showCustom ? ( + + + + + + + ) : ( + setShowCustom(true)}> + Type your own answer + + ))} + + + + + Dismiss + + {(request.questions.length > 1 || q.multiple) && ( + { + if (current < request.questions.length - 1) { + setCurrent(current + 1) + } else { + onReply(answers) + } + }} + > + {current < request.questions.length - 1 ? "Next" : "Submit"} + + )} + + + ) +} + +const s = StyleSheet.create({ + card: { + margin: 12, + padding: 16, + backgroundColor: "#f5f3ff", + borderRadius: 12, + borderWidth: 1, + borderColor: "#ede9fe", + }, + cardDark: { backgroundColor: "#1a1a2e", borderColor: "#2a2a3e" }, + header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, + title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" }, + textWhite: { color: "#ffffff" }, + question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 }, + metaDark: { color: "#666666" }, + + options: { gap: 8 }, + option: { + padding: 12, + borderRadius: 8, + backgroundColor: "#ffffff", + borderWidth: 1, + borderColor: "#e5e5e5", + }, + optionDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" }, + optionSelected: { borderColor: "#8b5cf6", backgroundColor: "#f5f3ff" }, + optionSelectedDark: { borderColor: "#8b5cf6", backgroundColor: "#2a1a3e" }, + optionLabel: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" }, + optionLabelSelected: { color: "#6d28d9" }, + optionDesc: { fontSize: 12, color: "#666666", marginTop: 2 }, + + customRow: { flexDirection: "row", gap: 8 }, + customInput: { + flex: 1, + backgroundColor: "#ffffff", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + borderWidth: 1, + borderColor: "#e5e5e5", + color: "#0a0a0a", + }, + customInputDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a", color: "#ffffff" }, + customSubmit: { justifyContent: "center", alignItems: "center", padding: 8 }, + + footer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 12 }, + dismiss: { fontSize: 14, color: "#999999" }, + submitBtn: { backgroundColor: "#8b5cf6", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 }, + submitBtnDark: { backgroundColor: "#7c3aed" }, + submitText: { color: "#ffffff", fontWeight: "600", fontSize: 14 }, +}) diff --git a/src/components/chat/ReasoningBlock.tsx b/src/components/chat/ReasoningBlock.tsx new file mode 100644 index 0000000..0063e90 --- /dev/null +++ b/src/components/chat/ReasoningBlock.tsx @@ -0,0 +1,48 @@ +import { useState } from "react" +import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +interface Props { + text: string + isDark: boolean +} + +export function ReasoningBlock({ text, isDark }: Props) { + const [expanded, setExpanded] = useState(false) + + return ( + setExpanded(!expanded)} + activeOpacity={0.7} + > + + + Thinking + + + {expanded && ( + + {text} + + )} + + ) +} + +const s = StyleSheet.create({ + block: { + backgroundColor: "#fffbeb", + borderRadius: 8, + padding: 10, + marginBottom: 8, + borderWidth: 1, + borderColor: "#fef3c7", + }, + blockDark: { backgroundColor: "#1a1a0a", borderColor: "#333300" }, + header: { flexDirection: "row", alignItems: "center", gap: 6 }, + label: { fontSize: 12, fontWeight: "600", color: "#92400e", flex: 1 }, + labelDark: { color: "#f59e0b" }, + text: { fontSize: 13, lineHeight: 20, color: "#78350f", marginTop: 8 }, + textDark: { color: "#d4a574" }, +}) diff --git a/src/components/chat/SessionInfo.tsx b/src/components/chat/SessionInfo.tsx new file mode 100644 index 0000000..68d4289 --- /dev/null +++ b/src/components/chat/SessionInfo.tsx @@ -0,0 +1,338 @@ +import { useMemo } from "react" +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import type { Message, Session } from "../../lib/sdk" +import type { Provider } from "../../stores/catalog" + +interface Props { + session: Session | null + messages: Message[] + providers: Provider[] + visible: boolean + isDark: boolean + hasMore: boolean + loadingAll: boolean + onLoadAll: () => void + onScrollToTop: () => void + onClose: () => void +} + +function compact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + +function formatCost(cost: number): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cost) +} + +function formatTime(ts: number): string { + const diff = Date.now() - ts + const mins = Math.floor(diff / 60_000) + if (mins < 1) return "just now" + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }) +} + +export function SessionInfo({ + session, + messages, + providers, + visible, + isDark, + hasMore, + loadingAll, + onLoadAll, + onScrollToTop, + onClose, +}: Props) { + // Match TUI: last assistant message tokens (context window), cumulative cost + const stats = useMemo(() => { + let cost = 0 + let last: Message | null = null + + for (const msg of messages) { + if (msg.role !== "assistant") continue + if (msg.cost) cost += msg.cost + if (msg.tokens && msg.tokens.output > 0) last = msg + } + + // Last assistant message token breakdown (what the TUI shows) + const tokens = last?.tokens + const input = tokens?.input || 0 + const output = tokens?.output || 0 + const reasoning = tokens?.reasoning || 0 + const cacheRead = tokens?.cache?.read || 0 + const cacheWrite = tokens?.cache?.write || 0 + const total = input + output + reasoning + cacheRead + cacheWrite + + // Find context limit from provider catalog + let context = 0 + if (last?.providerID && last?.modelID) { + const provider = providers.find((p) => p.id === last!.providerID) + const model = provider?.models.find((m) => m.id === last!.modelID) + context = model?.limit?.context || 0 + } + const percent = context > 0 ? Math.round((total / context) * 100) : 0 + + return { cost, input, output, reasoning, cacheRead, cacheWrite, total, percent, context } + }, [messages, providers]) + + if (!visible) return null + + const hasTokens = stats.total > 0 + const hasCost = stats.cost > 0 + const summary = session?.summary + const created = session?.time.created + const updated = session?.time.updated + + return ( + + {/* Top row: tokens + context % + cost — matches TUI header */} + + + + {hasTokens && ( + + {stats.total.toLocaleString()} + {stats.percent > 0 && {` ${stats.percent}%`}} + + )} + {hasCost && ({formatCost(stats.cost)})} + {!hasTokens && !hasCost && No usage data yet} + + + + + + + {/* Context bar */} + {stats.percent > 0 && ( + + 80 ? s.barWarn : stats.percent > 50 ? s.barMid : s.barOk, + ]} + /> + + )} + + {/* Token breakdown pills */} + {hasTokens && ( + + + + {stats.reasoning > 0 && } + {stats.cacheRead > 0 && } + {stats.cacheWrite > 0 && ( + + )} + + )} + + {/* Session metadata */} + + {created && } + {updated && updated !== created && ( + + )} + + {summary && summary.files > 0 && ( + + )} + {session?.share?.url && } + + + {/* Navigation actions */} + + {hasMore && ( + + {loadingAll ? ( + + ) : ( + + )} + {loadingAll ? "Loading..." : "Load all messages"} + + )} + {messages.length > 0 && ( + + + Jump to beginning + + )} + + + ) +} + +function MetaItem({ icon, label, value, isDark }: { icon: string; label: string; value: string; isDark: boolean }) { + return ( + + + {label} + {value} + + ) +} + +function TokenPill({ label, value, color, isDark }: { label: string; value: number; color: string; isDark: boolean }) { + return ( + + + {label} + {compact(value)} + + ) +} + +const s = StyleSheet.create({ + container: { + paddingHorizontal: 12, + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: "#e5e5e5", + backgroundColor: "#fafafa", + gap: 8, + }, + containerDark: { + borderBottomColor: "#1a1a1a", + backgroundColor: "#111111", + }, + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + costRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + tokens: { + fontSize: 14, + fontWeight: "700", + color: "#0a0a0a", + fontVariant: ["tabular-nums"], + }, + percent: { + fontSize: 13, + fontWeight: "500", + }, + cost: { + fontSize: 13, + color: "#999999", + fontVariant: ["tabular-nums"], + }, + bar: { + height: 4, + borderRadius: 2, + backgroundColor: "#e5e5e5", + overflow: "hidden", + }, + barDark: { + backgroundColor: "#2a2a2a", + }, + barFill: { + height: "100%", + borderRadius: 2, + }, + barOk: { backgroundColor: "#3b82f6" }, + barMid: { backgroundColor: "#f59e0b" }, + barWarn: { backgroundColor: "#ef4444" }, + breakdown: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + pill: { + flexDirection: "row", + alignItems: "center", + gap: 4, + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 8, + paddingVertical: 3, + backgroundColor: "#ffffff", + }, + dot: { + width: 6, + height: 6, + borderRadius: 3, + }, + pillLabel: { + fontSize: 11, + color: "#666666", + }, + pillValue: { + fontSize: 11, + fontWeight: "600", + color: "#0a0a0a", + fontVariant: ["tabular-nums"], + }, + meta: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + metaItem: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + metaLabel: { + fontSize: 11, + color: "#999999", + }, + metaValue: { + fontSize: 11, + fontWeight: "600", + color: "#666666", + }, + metaValueDark: { + color: "#aaaaaa", + }, + actions: { + flexDirection: "row", + gap: 8, + }, + action: { + flexDirection: "row", + alignItems: "center", + gap: 5, + backgroundColor: "#ffffff", + borderWidth: 1, + borderColor: "#e5e5e5", + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6, + }, + actionDark: { + backgroundColor: "#1a1a1a", + borderColor: "#2a2a2a", + }, + actionText: { + fontSize: 12, + color: "#666666", + fontWeight: "500", + }, + textDark: { color: "#e5e5e5" }, + dimDark: { color: "#666666" }, +}) diff --git a/src/components/chat/SlashPopover.tsx b/src/components/chat/SlashPopover.tsx new file mode 100644 index 0000000..ba3dea2 --- /dev/null +++ b/src/components/chat/SlashPopover.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react" +import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native" +import { Ionicons } from "@expo/vector-icons" + +export interface SlashCommand { + trigger: string + title: string + description?: string + icon: string + type: "builtin" | "custom" +} + +interface Props { + query: string + commands: SlashCommand[] + isDark: boolean + onSelect: (cmd: SlashCommand) => void +} + +export function SlashPopover({ query, commands, isDark, onSelect }: Props) { + const filtered = useMemo(() => { + const q = query.toLowerCase() + return commands.filter((c) => c.trigger.toLowerCase().startsWith(q) || c.title.toLowerCase().includes(q)) + }, [query, commands]) + + if (filtered.length === 0) return null + + return ( + + + {filtered.map((cmd) => ( + onSelect(cmd)}> + + + /{cmd.trigger} + {cmd.description && ( + + {cmd.description} + + )} + + {cmd.type === "custom" && ( + + cmd + + )} + + ))} + + + ) +} + +const s = StyleSheet.create({ + popover: { + backgroundColor: "#ffffff", + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + maxHeight: 220, + }, + popoverDark: { backgroundColor: "#1a1a1a", borderTopColor: "#2a2a2a" }, + scroll: { paddingVertical: 4 }, + item: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 16, + paddingVertical: 10, + }, + itemDark: {}, + textCol: { flex: 1 }, + trigger: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" }, + textWhite: { color: "#ffffff" }, + desc: { fontSize: 12, color: "#999999", marginTop: 1 }, + metaDark: { color: "#666666" }, + badge: { + backgroundColor: "#f3e8ff", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + badgeDark: { backgroundColor: "#2a1a3e" }, + badgeText: { fontSize: 10, color: "#8b5cf6", fontWeight: "600" }, +}) diff --git a/src/components/chat/StatusIndicator.tsx b/src/components/chat/StatusIndicator.tsx new file mode 100644 index 0000000..cbc4c37 --- /dev/null +++ b/src/components/chat/StatusIndicator.tsx @@ -0,0 +1,46 @@ +import { View, Text, StyleSheet, ActivityIndicator } from "react-native" +import { useEvents } from "../../stores/events" +import { useSessions } from "../../stores/sessions" + +interface Props { + sessionID: string + isDark: boolean +} + +export function StatusIndicator({ sessionID, isDark }: Props) { + const status = useEvents((s) => s.sessionStatus[sessionID]) + const text = useEvents((s) => s.statusText[sessionID]) + const optimistic = useSessions((s) => s.sending[sessionID]) + + // SSE status is the source of truth. The optimistic `sending` flag only + // covers the gap between the user tapping send and SSE confirming busy. + // Once SSE reports idle, the indicator hides regardless of the optimistic flag. + const sseBusy = status && status.type !== "idle" + const busy = sseBusy || (optimistic && !status) + if (!busy) return null + + const label = status?.type === "retry" ? `Retrying (attempt ${status.attempt})...` : text || "Working..." + + return ( + + + {label} + + ) +} + +const s = StyleSheet.create({ + bar: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: "#f5f3ff", + borderTopWidth: 1, + borderTopColor: "#e5e5e5", + }, + barDark: { backgroundColor: "#1a1a2e", borderTopColor: "#2a2a2a" }, + text: { fontSize: 13, color: "#6d28d9", fontWeight: "500" }, + textDark: { color: "#a78bfa" }, +}) diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx new file mode 100644 index 0000000..90dbf6e --- /dev/null +++ b/src/components/chat/ToolCallCard.tsx @@ -0,0 +1,454 @@ +import { useState, useCallback } from "react" +import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import type { Part } from "../../lib/sdk" +import { DiffView } from "./DiffView" + +const TOOL_ICONS: Record = { + read: "glasses-outline", + list: "list-outline", + glob: "search-outline", + grep: "search-outline", + webfetch: "globe-outline", + edit: "code-slash-outline", + write: "create-outline", + apply_patch: "git-merge-outline", + bash: "terminal-outline", + task: "git-branch-outline", + todowrite: "checkbox-outline", + todoread: "checkbox-outline", + question: "chatbubble-ellipses-outline", + codesearch: "search-outline", + websearch: "globe-outline", +} + +const mono = Platform.OS === "ios" ? "Menlo" : "monospace" + +function statusColor(status: string): string { + if (status === "completed") return "#22c55e" + if (status === "error") return "#ef4444" + if (status === "running") return "#f59e0b" + return "#888888" +} + +// --- Tool-specific detail renderers --- + +function BashDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) { + const cmd = typeof input === "object" && input !== null ? (input as Record).command : undefined + const out = typeof output === "string" ? output : undefined + return ( + + {typeof cmd === "string" && ( + + + $ + {cmd} + + + )} + {out !== undefined && out.length > 0 && ( + + + {out} + + + )} + + ) +} + +function ReadDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const file = typeof input === "object" && input !== null ? (input as Record).filePath : undefined + const offset = typeof input === "object" && input !== null ? (input as Record).offset : undefined + const limit = typeof input === "object" && input !== null ? (input as Record).limit : undefined + const range = offset || limit ? ` (${offset || 0}..${limit || "end"})` : "" + return ( + + {typeof file === "string" && ( + + {file} + {range} + + )} + + ) +} + +function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const file = typeof input === "object" && input !== null ? (input as Record).filePath : undefined + const content = typeof input === "object" && input !== null ? (input as Record).content : undefined + return ( + + {typeof file === "string" && ( + + {file} + + )} + {typeof content === "string" && content.length > 0 && ( + + + {content} + + + )} + + ) +} + +function EditDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) { + const file = typeof input === "object" && input !== null ? (input as Record).filePath : undefined + const old = typeof input === "object" && input !== null ? (input as Record).oldString : undefined + const replacement = + typeof input === "object" && input !== null ? (input as Record).newString : undefined + + // If we have old/new strings, show as diff + if (typeof old === "string" && typeof replacement === "string") { + return ( + + {typeof file === "string" && ( + + {file} + + )} + + + ) + } + + // Fallback: show raw output + const text = typeof output === "string" ? output : JSON.stringify(output, null, 2) + return ( + + {typeof file === "string" && ( + + {file} + + )} + {text && ( + + + {text} + + + )} + + ) +} + +function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const patch = typeof input === "object" && input !== null ? (input as Record).patch : undefined + return ( + + {typeof patch === "string" && patch.length > 0 && ( + + + {patch} + + + )} + + ) +} + +function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) { + const pattern = typeof input === "object" && input !== null ? (input as Record).pattern : undefined + const path = typeof input === "object" && input !== null ? (input as Record).path : undefined + const results = typeof output === "string" ? output : undefined + return ( + + {typeof pattern === "string" && ( + + Pattern: {pattern} + {typeof path === "string" ? ` in ${path}` : ""} + + )} + {results && results.length > 0 && ( + + + {results} + + + )} + + ) +} + +function WebfetchDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const url = typeof input === "object" && input !== null ? (input as Record).url : undefined + return ( + + {typeof url === "string" && ( + + {url} + + )} + + ) +} + +function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const description = + typeof input === "object" && input !== null ? (input as Record).description : undefined + const prompt = typeof input === "object" && input !== null ? (input as Record).prompt : undefined + return ( + + {typeof description === "string" && {description}} + {typeof prompt === "string" && prompt.length > 0 && ( + + + {prompt} + + + )} + + ) +} + +function TodoDetail({ input, isDark }: { input: unknown; isDark: boolean }) { + const todos = typeof input === "object" && input !== null ? (input as Record).todos : undefined + if (!Array.isArray(todos)) return null + return ( + + {todos.map((t, i) => { + const item = t as Record + const done = item.status === "completed" + return ( + + + + {String(item.content || item.title || "")} + + + ) + })} + + ) +} + +function GenericDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) { + const text = + typeof output === "string" + ? output + : output !== undefined && output !== null + ? JSON.stringify(output, null, 2) + : typeof input === "object" && input !== null + ? JSON.stringify(input, null, 2) + : undefined + if (!text || text.length === 0) return null + return ( + + + + {text} + + + + ) +} + +function ToolDetail({ tool, isDark }: { tool: Part; isDark: boolean }) { + const name = tool.tool || "" + const input = tool.state?.input + const output = tool.state?.output + + switch (name) { + case "bash": + return + case "read": + return + case "write": + return + case "edit": + return + case "apply_patch": + return + case "glob": + case "grep": + case "list": + case "codesearch": + return + case "webfetch": + case "websearch": + return + case "task": + return + case "todowrite": + return + default: + return + } +} + +// --- Error display --- +function ErrorBanner({ message, isDark }: { message: string; isDark: boolean }) { + return ( + + + + {message} + + + ) +} + +// --- Duration display --- +function duration(start?: number, end?: number): string | null { + if (!start || !end) return null + const ms = end - start + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +// --- Main component --- +interface Props { + tool: Part + isDark: boolean +} + +export function ToolCallCard({ tool, isDark }: Props) { + const [expanded, setExpanded] = useState(false) + const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline" + const status = tool.state?.status || "pending" + const color = statusColor(status) + const error = tool.state?.error?.message + const elapsed = duration(tool.state?.time?.start, tool.state?.time?.end) + const hasDetail = tool.state?.input !== undefined || tool.state?.output !== undefined || error + + const toggle = useCallback(() => { + if (hasDetail) setExpanded((v) => !v) + }, [hasDetail]) + + return ( + + {/* Header row */} + + + + + {tool.state?.title || tool.tool || "Tool"} + + {elapsed && {elapsed}} + + + {status === "running" && } + {status === "completed" && } + {status === "error" && } + {hasDetail && ( + + )} + + + + {/* Error banner */} + {error && !expanded && } + + {/* Expanded detail */} + {expanded && ( + + {error && } + + + )} + + ) +} + +const s = StyleSheet.create({ + card: { + backgroundColor: "#ffffff", + padding: 10, + borderRadius: 8, + marginTop: 8, + borderWidth: 1, + borderColor: "#f0f0f0", + }, + cardDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" }, + cardError: { borderColor: "#fecaca" }, + cardErrorDark: { borderColor: "#7f1d1d" }, + + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + headerLeft: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }, + headerRight: { flexDirection: "row", alignItems: "center", gap: 6 }, + name: { fontSize: 13, fontWeight: "500", color: "#0a0a0a", flex: 1 }, + nameDark: { color: "#e5e5e5" }, + elapsed: { fontSize: 11, color: "#999999" }, + elapsedDark: { color: "#666666" }, + + // Error + errorBanner: { + flexDirection: "row", + alignItems: "flex-start", + gap: 6, + marginTop: 8, + padding: 8, + backgroundColor: "#fef2f2", + borderRadius: 6, + }, + errorBannerDark: { backgroundColor: "#1a0a0a" }, + errorText: { fontSize: 12, color: "#dc2626", flex: 1, lineHeight: 18 }, + + // Detail + detailScroll: { maxHeight: 300, marginTop: 8 }, + detailSection: { gap: 4 }, + detailFile: { + fontSize: 12, + fontFamily: mono, + color: "#6d28d9", + backgroundColor: "#f5f3ff", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + overflow: "hidden", + }, + detailFileDark: { color: "#a78bfa", backgroundColor: "#1a1a2e" }, + detailMeta: { fontSize: 12, color: "#666666", lineHeight: 18 }, + detailMetaDark: { color: "#888888" }, + + // Code block + codeBlock: { + backgroundColor: "#f8f8f8", + borderRadius: 6, + padding: 10, + }, + codeBlockDark: { backgroundColor: "#1a1a1a" }, + codePre: { + fontSize: 12, + fontFamily: mono, + color: "#0a0a0a", + lineHeight: 18, + }, + codePteDark: { color: "#e5e5e5" }, + codePrompt: { color: "#8b5cf6", fontWeight: "700" }, + + // Todo + todoRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: 8, + paddingVertical: 3, + }, + todoText: { fontSize: 13, color: "#0a0a0a", flex: 1, lineHeight: 20 }, + todoTextDark: { color: "#e5e5e5" }, + todoDone: { textDecorationLine: "line-through", color: "#999999" }, +}) diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts new file mode 100644 index 0000000..c6ab19d --- /dev/null +++ b/src/components/chat/index.ts @@ -0,0 +1,12 @@ +export { MessageBubble } from "./MessageBubble" +export { ToolCallCard } from "./ToolCallCard" +export { DiffView } from "./DiffView" +export { ReasoningBlock } from "./ReasoningBlock" +export { PermissionPrompt } from "./PermissionPrompt" +export { QuestionPrompt } from "./QuestionPrompt" +export { StatusIndicator } from "./StatusIndicator" +export { SlashPopover, type SlashCommand } from "./SlashPopover" +export { ModelPicker } from "./ModelPicker" +export { ImageAttachments, type Attachment } from "./ImageAttachments" +export { DirectorySwitcher } from "./DirectorySwitcher" +export { SessionInfo } from "./SessionInfo" diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx new file mode 100644 index 0000000..a7f529d --- /dev/null +++ b/src/components/markdown/CodeBlock.tsx @@ -0,0 +1,85 @@ +import { useState } from "react" +import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native" +import * as Clipboard from "expo-clipboard" + +interface Props { + code: string + language?: string +} + +export function CodeBlock({ code, language }: Props) { + const isDark = useColorScheme() === "dark" + const [copied, setCopied] = useState(false) + + const copy = async () => { + try { + await Clipboard.setStringAsync(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch {} + } + + return ( + + + {language || "code"} + + {copied ? "Copied!" : "Copy"} + + + + {code} + + + ) +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: "#f5f5f5", + borderRadius: 8, + marginVertical: 8, + overflow: "hidden", + }, + containerDark: { + backgroundColor: "#1a1a1a", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: "#e8e8e8", + }, + headerDark: { + backgroundColor: "#2a2a2a", + }, + language: { + fontSize: 11, + fontWeight: "600", + color: "#666666", + textTransform: "uppercase", + }, + languageDark: { + color: "#888888", + }, + copyBtn: { + fontSize: 11, + color: "#8b5cf6", + fontWeight: "600", + }, + copyBtnDark: { + color: "#a78bfa", + }, + code: { + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + fontSize: 13, + lineHeight: 20, + color: "#1a1a1a", + padding: 12, + }, + codeDark: { + color: "#e5e5e5", + }, +}) diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx new file mode 100644 index 0000000..85a9fe6 --- /dev/null +++ b/src/components/markdown/Markdown.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react" +import { View, Text, useColorScheme, Platform, type ViewStyle, type TextStyle } from "react-native" +import RNMarkdown, { Renderer } from "react-native-marked" +import { CodeBlock } from "./CodeBlock" + +class CustomRenderer extends Renderer { + code(text: string, language?: string, containerStyle?: ViewStyle, _textStyle?: TextStyle) { + return ( + + + + ) + } + + codespan(text: string, styles?: TextStyle): ReactNode { + return ( + + {text} + + ) + } +} + +const renderer = new CustomRenderer() + +const mono = Platform.OS === "ios" ? "Menlo" : "monospace" + +const lightTheme = { + text: { color: "#0a0a0a", fontSize: 15, lineHeight: 22 }, + paragraph: { marginTop: 0, marginBottom: 8 }, + heading1: { fontSize: 22, fontWeight: "700" as const, color: "#0a0a0a", marginBottom: 8, marginTop: 12 }, + heading2: { fontSize: 19, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 6, marginTop: 10 }, + heading3: { fontSize: 16, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 4, marginTop: 8 }, + link: { color: "#8b5cf6" }, + blockquote: { + backgroundColor: "transparent", + borderLeftWidth: 3, + borderLeftColor: "#d1d5db", + paddingLeft: 12, + paddingVertical: 2, + marginVertical: 4, + }, + code: { + backgroundColor: "#e8e5f0", + color: "#6d28d9", + fontFamily: mono, + fontSize: 13, + paddingHorizontal: 5, + paddingVertical: 2, + borderRadius: 4, + }, + codespan: { + backgroundColor: "#e8e5f0", + color: "#6d28d9", + fontFamily: mono, + fontSize: 13, + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: 3, + }, + list: { marginBottom: 4 }, + listItem: { marginBottom: 2 }, + hr: { backgroundColor: "#e5e5e5", height: 1, marginVertical: 12 }, + strong: { fontWeight: "700" as const }, + em: { fontStyle: "italic" as const }, + strikethrough: { textDecorationLine: "line-through" as const }, + image: { borderRadius: 8 }, +} + +const darkTheme = { + ...lightTheme, + text: { ...lightTheme.text, color: "#e5e5e5" }, + heading1: { ...lightTheme.heading1, color: "#ffffff" }, + heading2: { ...lightTheme.heading2, color: "#ffffff" }, + heading3: { ...lightTheme.heading3, color: "#ffffff" }, + link: { color: "#a78bfa" }, + blockquote: { + ...lightTheme.blockquote, + borderLeftColor: "#4a4a5a", + }, + code: { + ...lightTheme.code, + backgroundColor: "#2a2040", + color: "#c4b5fd", + }, + codespan: { + ...lightTheme.codespan, + backgroundColor: "#2a2040", + color: "#c4b5fd", + }, + hr: { ...lightTheme.hr, backgroundColor: "#2a2a2a" }, +} + +interface Props { + children: string +} + +export function Markdown({ children }: Props) { + const isDark = useColorScheme() === "dark" + const theme = isDark ? darkTheme : lightTheme + + if (!children?.trim()) return null + + return ( + + ) +} diff --git a/src/components/markdown/index.tsx b/src/components/markdown/index.tsx new file mode 100644 index 0000000..c298ca4 --- /dev/null +++ b/src/components/markdown/index.tsx @@ -0,0 +1,2 @@ +export { Markdown } from "./Markdown" +export { CodeBlock } from "./CodeBlock" diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..b0f371c --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,174 @@ +import * as Notifications from "expo-notifications" +import * as Device from "expo-device" +import { Platform, AppState } from "react-native" + +// --------------------------------------------------------------------------- +// Categories — every notification belongs to exactly one +// --------------------------------------------------------------------------- + +export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const +export type Category = (typeof categories)[number] + +// Per-category metadata used by both the notification module and the settings UI +export const categoryMeta: Record = { + permissions: { + label: "Permission Requests", + description: "When a tool needs approval to proceed", + icon: "shield-checkmark", + }, + questions: { + label: "Questions", + description: "When the assistant needs your input", + icon: "help-circle", + }, + completed: { + label: "Task Completed", + description: "When a session finishes processing", + icon: "checkmark-circle", + }, + errors: { + label: "Errors", + description: "When a session encounters a problem", + icon: "alert-circle", + }, + connection: { + label: "Connection", + description: "When the app loses connection for an extended period", + icon: "wifi", + }, +} + +// Default state — permissions and questions on (they block work), the rest off +export const defaultPreferences: Record = { + permissions: true, + questions: true, + completed: false, + errors: true, + connection: true, +} + +// --------------------------------------------------------------------------- +// Notification payload — what callers pass in +// --------------------------------------------------------------------------- + +export interface Payload { + category: Category + title: string + body: string + sessionId: string + dedupeKey?: string + dedupeCooldownMs?: number +} + +// Data embedded in the notification for tap handling +interface NotificationData { + category: Category + sessionId: string +} + +// --------------------------------------------------------------------------- +// Preferences accessor — injected to avoid circular imports with the store +// --------------------------------------------------------------------------- + +type PreferencesAccessor = () => Record + +let preferences: PreferencesAccessor = () => defaultPreferences +const dedupeSentAt = new Map() + +export function configure(accessor: PreferencesAccessor) { + preferences = accessor +} + +// --------------------------------------------------------------------------- +// Setup & permissions +// --------------------------------------------------------------------------- + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}) + +export async function setup(): Promise { + if (!Device.isDevice) return false + + const { status: existing } = await Notifications.getPermissionsAsync() + if (existing === "granted") { + await ensureChannel() + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + if (status !== "granted") return false + + await ensureChannel() + return true +} + +export async function granted(): Promise { + const { status } = await Notifications.getPermissionsAsync() + return status === "granted" +} + +async function ensureChannel() { + if (Platform.OS !== "android") return + await Notifications.setNotificationChannelAsync("prompts", { + name: "Session Prompts & Updates", + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + sound: "default", + }) +} + +// --------------------------------------------------------------------------- +// Send — single entry point, decides whether to fire +// --------------------------------------------------------------------------- + +export async function send(payload: Payload) { + const prefs = preferences() + if (!prefs[payload.category]) return + if (AppState.currentState === "active") return + if (!(await granted())) return + + if (payload.dedupeKey) { + const now = Date.now() + const lastSent = dedupeSentAt.get(payload.dedupeKey) || 0 + const cooldown = payload.dedupeCooldownMs ?? 30_000 + if (now - lastSent < cooldown) return + dedupeSentAt.set(payload.dedupeKey, now) + } + + await Notifications.scheduleNotificationAsync({ + content: { + title: payload.title, + body: payload.body, + data: { + category: payload.category, + sessionId: payload.sessionId, + } satisfies NotificationData as Record, + sound: "default", + ...(Platform.OS === "android" ? { channelId: "prompts" } : {}), + }, + trigger: null, + }) +} + +// --------------------------------------------------------------------------- +// Tap handler — returns cleanup function +// --------------------------------------------------------------------------- + +export function onTap(handler: (data: NotificationData) => void) { + const subscription = Notifications.addNotificationResponseReceivedListener((response) => { + const raw = response.notification.request.content.data + const data = raw as unknown as NotificationData | undefined + if (!data) return + if (data.sessionId || data.category === "connection") { + handler(data) + } + }) + return () => subscription.remove() +} diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts new file mode 100644 index 0000000..5c9e1d7 --- /dev/null +++ b/src/lib/sdk.ts @@ -0,0 +1,434 @@ +// SDK client wrapper for React Native +// We create our own lightweight client that mirrors the opencode SDK patterns +// but works in React Native environment +// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE +import { fetch as expoFetch } from "expo/fetch" + +export interface ClientConfig { + baseUrl: string + directory?: string + auth?: { + username: string + password: string + } +} + +export interface Session { + id: string + slug: string + projectID: string + directory: string + parentID?: string + title: string + version: string + share?: { url: string } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + summary?: { + additions: number + deletions: number + files: number + } +} + +export interface Message { + id: string + sessionID: string + role: "user" | "assistant" + parentID?: string + time: { + created: number + completed?: number + } + // User message fields + agent?: string + model?: { providerID: string; modelID: string } + // Assistant message fields + modelID?: string + providerID?: string + cost?: number + tokens?: { + input: number + output: number + reasoning?: number + cache?: { read: number; write: number } + } + error?: { message: string } + finish?: string +} + +// API returns messages with parts embedded +export interface MessageWithParts { + info: Message + parts: Part[] +} + +export interface Part { + id: string + sessionID?: string + messageID: string + type: + | "text" + | "reasoning" + | "tool" + | "file" + | "snapshot" + | "patch" + | "step-start" + | "step-finish" + | "subtask" + | "retry" + | "compaction" + | "agent" + // Text / reasoning part + text?: string + // Tool part + tool?: string + callID?: string + state?: { + status: "pending" | "running" | "completed" | "error" + input?: unknown + output?: unknown + title?: string + error?: { message: string } + time?: { start?: number; end?: number } + } + // Timing + time?: { start?: number; end?: number } + // File part + mime?: string + url?: string + filename?: string +} + +export interface Agent { + name: string + description?: string + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + topP?: number + temperature?: number + color?: string + model?: { modelID: string; providerID: string } + prompt?: string + options: Record + steps?: number +} + +export interface Command { + name: string + description?: string + agent?: string + model?: string + mcp?: boolean + template: string + subtask?: boolean + hints: string[] +} + +export interface Project { + id: string + name?: string + path: { + cwd: string + root: string + absolute: string + } +} + +export interface Event { + type: string + properties: Record +} + +export interface HealthResponse { + healthy: boolean + version: string +} + +const REQUEST_TIMEOUT_MS = 30_000 + +function createHeaders(config: ClientConfig): HeadersInit { + const headers: HeadersInit = { + "Content-Type": "application/json", + } + + if (config.directory) { + const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory + headers["x-opencode-directory"] = encoded + } + + if (config.auth) { + const credentials = btoa(`${config.auth.username}:${config.auth.password}`) + headers["Authorization"] = `Basic ${credentials}` + } + + return headers +} + +async function request(config: ClientConfig, path: string, options: RequestInit = {}): Promise { + const url = `${config.baseUrl}${path}` + const headers = { ...createHeaders(config), ...options.headers } + const response = await fetchWithTimeout(url, { + ...options, + headers, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`API Error: ${response.status} - ${error}`) + } + + return response.json() +} + +async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { + const parentSignal = options.signal + if (parentSignal?.aborted) throw new Error("Request aborted") + + const controller = new AbortController() + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + controller.abort() + }, REQUEST_TIMEOUT_MS) + const onParentAbort = () => controller.abort() + parentSignal?.addEventListener("abort", onParentAbort) + + try { + return await fetch(url, { ...options, signal: controller.signal }) + } catch (error) { + if (timedOut) { + throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`) + } + throw error + } finally { + clearTimeout(timeout) + parentSignal?.removeEventListener("abort", onParentAbort) + } +} + +export function createClient(config: ClientConfig) { + return { + global: { + health: () => request(config, "/global/health"), + // SSE event stream - returns async iterator + // Pass an AbortSignal to cancel the connection + async *events(signal?: AbortSignal): AsyncGenerator { + const url = `${config.baseUrl}/global/event` + const headers = createHeaders(config) + // Remove Content-Type for SSE (it's text/event-stream) + delete (headers as Record)["Content-Type"] + + // Must use expo/fetch for ReadableStream support on native + const response = await expoFetch(url, { headers, signal }) + if (!response.ok || !response.body) { + throw new Error(`Failed to connect to event stream: ${response.status}`) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6) + if (data && data !== "[DONE]") { + try { + yield JSON.parse(data) + } catch (err) { + console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err) + } + } + } + } + } + } finally { + reader.releaseLock() + } + }, + }, + + project: { + list: () => request(config, "/project"), + current: () => request(config, "/project/current"), + }, + + path: { + get: () => + request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"), + }, + + session: { + list: (params?: { roots?: boolean; limit?: number; search?: string }) => { + const query = new URLSearchParams() + if (params?.roots) query.set("roots", "true") + if (params?.limit) query.set("limit", String(params.limit)) + if (params?.search) query.set("search", params.search) + const qs = query.toString() + return request(config, `/session${qs ? `?${qs}` : ""}`) + }, + + get: (sessionID: string) => request(config, `/session/${sessionID}`), + + create: (params?: { title?: string }) => + request(config, "/session", { + method: "POST", + body: JSON.stringify(params || {}), + }), + + delete: (sessionID: string) => request(config, `/session/${sessionID}`, { method: "DELETE" }), + + update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) => + request(config, `/session/${sessionID}`, { + method: "PATCH", + body: JSON.stringify(params), + }), + + messages: (sessionID: string, params?: { limit?: number }) => { + const query = new URLSearchParams() + if (params?.limit) query.set("limit", String(params.limit)) + const qs = query.toString() + return request(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`) + }, + + // Sends a message and returns the response + // Fire-and-forget async prompt - SSE events drive all real-time updates + prompt: async ( + sessionID: string, + params: { + parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }> + model?: { providerID: string; modelID: string } + agent?: string + variant?: string + }, + ): Promise => { + const url = `${config.baseUrl}/session/${sessionID}/prompt_async` + const headers = createHeaders(config) + const body = JSON.stringify(params) + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to send message: ${response.status} - ${error}`) + } + }, + + command: async ( + sessionID: string, + params: { + command: string + arguments: string + agent?: string + model?: string + variant?: string + parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }> + }, + ): Promise => { + const url = `${config.baseUrl}/session/${sessionID}/command` + const headers = createHeaders(config) + + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body: JSON.stringify({ ...params, sessionID }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Failed to run command: ${response.status} - ${error}`) + } + }, + + abort: (sessionID: string) => request(config, `/session/${sessionID}/abort`, { method: "POST" }), + + diff: (sessionID: string, messageID?: string) => { + const qs = messageID ? `?messageID=${messageID}` : "" + return request(config, `/session/${sessionID}/diff${qs}`) + }, + }, + + permission: { + list: () => + request>(config, "/permission"), + + reply: (requestID: string, reply: "once" | "always" | "reject") => + request(config, `/permission/${requestID}/reply`, { + method: "POST", + body: JSON.stringify({ reply }), + }), + }, + + question: { + list: () => request>(config, "/question"), + + reply: (requestID: string, answers: string[][]) => + request(config, `/question/${requestID}/reply`, { + method: "POST", + body: JSON.stringify({ answers }), + }), + + reject: (requestID: string) => + request(config, `/question/${requestID}/reject`, { + method: "POST", + }), + }, + + agent: { + list: () => request(config, "/agent"), + }, + + command: { + list: () => request(config, "/command"), + }, + + provider: { + list: () => + request<{ + all: Array<{ + id: string + name: string + models: Record< + string, + { + id: string + name: string + attachment: boolean + reasoning: boolean + tool_call: boolean + cost?: { input: number; output: number } + limit: { context: number; output: number } + status?: "alpha" | "beta" | "deprecated" | "active" + } + > + }> + default: Record + connected: string[] + }>(config, "/provider"), + }, + + config: { + get: () => request(config, "/config"), + }, + } +} + +export type Client = ReturnType diff --git a/src/lib/speech.ts b/src/lib/speech.ts new file mode 100644 index 0000000..d73519b --- /dev/null +++ b/src/lib/speech.ts @@ -0,0 +1,80 @@ +import { useState, useCallback, useRef } from "react" +import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition" + +interface SpeechState { + listening: boolean + transcript: string + error: string | null +} + +interface SpeechActions { + start: () => Promise + stop: () => void + cancel: () => void +} + +export function useSpeech(onResult: (text: string) => void): SpeechState & SpeechActions { + const [listening, setListening] = useState(false) + const [transcript, setTranscript] = useState("") + const [error, setError] = useState(null) + const pending = useRef("") + + useSpeechRecognitionEvent("start", () => { + setListening(true) + setError(null) + setTranscript("") + pending.current = "" + }) + + useSpeechRecognitionEvent("end", () => { + setListening(false) + // Deliver final transcript + if (pending.current.trim()) { + onResult(pending.current.trim()) + } + setTranscript("") + pending.current = "" + }) + + useSpeechRecognitionEvent("result", (event) => { + const text = event.results[0]?.transcript || "" + pending.current = text + setTranscript(text) + }) + + useSpeechRecognitionEvent("error", (event) => { + // "no-speech" is not really an error — user just didn't say anything + if (event.error === "no-speech") { + setListening(false) + return + } + setError(event.message || event.error) + setListening(false) + }) + + const start = useCallback(async () => { + const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync() + if (!result.granted) { + setError("Microphone permission denied") + return + } + ExpoSpeechRecognitionModule.start({ + lang: "en-US", + interimResults: true, + continuous: true, + }) + }, []) + + const stop = useCallback(() => { + ExpoSpeechRecognitionModule.stop() + }, []) + + const cancel = useCallback(() => { + pending.current = "" + ExpoSpeechRecognitionModule.abort() + setListening(false) + setTranscript("") + }, []) + + return { listening, transcript, error, start, stop, cancel } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..d61a45c --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,32 @@ +// Connection types for multiple server support +export type ConnectionType = "local" | "tunnel" | "cloud" + +export interface ServerConnection { + id: string + name: string + type: ConnectionType + url: string + // For auth + username?: string + // Password stored separately in SecureStore + // Directory to use for this connection + directory?: string + // When last successfully connected + lastConnected?: number + // Is this the active connection? + active?: boolean +} + +export interface AppSettings { + // Require biometric auth to open app + requireBiometric: boolean + // Require biometric to send messages + requireBiometricForMessages: boolean + // Theme preference + theme: "light" | "dark" | "system" + // Show notifications for task completion + notifications: boolean +} + +// Re-export SDK types we'll use frequently +export type { Session, Message, Part, Project, Event, HealthResponse } from "./sdk" diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..7233073 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,135 @@ +import { create } from "zustand" +import * as LocalAuthentication from "expo-local-authentication" +import * as SecureStore from "expo-secure-store" + +const AUTH_SETTINGS_KEY = "opencode_auth_settings" + +interface AuthSettings { + requireBiometric: boolean + requireBiometricForMessages: boolean +} + +interface AuthState { + isAuthenticated: boolean + isLoading: boolean + hasBiometrics: boolean + biometricType: LocalAuthentication.AuthenticationType | null + settings: AuthSettings + error: string | null + + // Actions + initialize: () => Promise + authenticate: () => Promise + authenticateForMessage: () => Promise + updateSettings: (settings: Partial) => Promise + lock: () => void +} + +const DEFAULT_SETTINGS: AuthSettings = { + requireBiometric: false, + requireBiometricForMessages: false, +} + +export const useAuth = create((set, get) => ({ + isAuthenticated: false, + isLoading: true, + hasBiometrics: false, + biometricType: null, + settings: DEFAULT_SETTINGS, + error: null, + + initialize: async () => { + try { + set({ isLoading: true, error: null }) + + // Check biometric availability + const hasHardware = await LocalAuthentication.hasHardwareAsync() + const isEnrolled = await LocalAuthentication.isEnrolledAsync() + const hasBiometrics = hasHardware && isEnrolled + + let biometricType: LocalAuthentication.AuthenticationType | null = null + if (hasBiometrics) { + const types = await LocalAuthentication.supportedAuthenticationTypesAsync() + biometricType = types[0] || null + } + + // Load settings + const stored = await SecureStore.getItemAsync(AUTH_SETTINGS_KEY) + const settings: AuthSettings = stored ? JSON.parse(stored) : DEFAULT_SETTINGS + + // If biometric is not required, auto-authenticate + const isAuthenticated = !settings.requireBiometric + + set({ + hasBiometrics, + biometricType, + settings, + isAuthenticated, + isLoading: false, + }) + } catch (error) { + set({ + error: "Failed to initialize authentication", + isLoading: false, + isAuthenticated: true, // Fail open for usability + }) + } + }, + + authenticate: async () => { + const { settings, hasBiometrics } = get() + + if (!settings.requireBiometric || !hasBiometrics) { + set({ isAuthenticated: true }) + return true + } + + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: "Authenticate to access OpenCode", + fallbackLabel: "Use passcode", + disableDeviceFallback: false, + }) + + if (result.success) { + set({ isAuthenticated: true, error: null }) + return true + } + + set({ error: result.error || "Authentication failed" }) + return false + } catch (error) { + set({ error: "Authentication error" }) + return false + } + }, + + authenticateForMessage: async () => { + const { settings, hasBiometrics, isAuthenticated } = get() + + if (!isAuthenticated) return false + if (!settings.requireBiometricForMessages || !hasBiometrics) return true + + try { + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: "Authenticate to send message", + fallbackLabel: "Use passcode", + disableDeviceFallback: false, + }) + + return result.success + } catch { + return false + } + }, + + updateSettings: async (updates) => { + const settings = { ...get().settings, ...updates } + await SecureStore.setItemAsync(AUTH_SETTINGS_KEY, JSON.stringify(settings)) + set({ settings }) + }, + + lock: () => { + set({ isAuthenticated: false }) + }, +})) diff --git a/src/stores/catalog.ts b/src/stores/catalog.ts new file mode 100644 index 0000000..e926d64 --- /dev/null +++ b/src/stores/catalog.ts @@ -0,0 +1,139 @@ +import { create } from "zustand" +import { useConnections } from "./connections" +import type { Agent, Command } from "../lib/sdk" + +export interface ProviderModel { + id: string + name: string + reasoning: boolean + attachment: boolean + limit?: { context: number; output: number } +} + +export interface Provider { + id: string + name: string + connected: boolean + models: ProviderModel[] +} + +interface ModelSelection { + providerID: string + modelID: string +} + +interface CatalogState { + agents: Agent[] + commands: Command[] + providers: Provider[] + defaults: Record + // Current selections + agent: string // agent name, e.g. "build" + model: ModelSelection | null + loaded: boolean + + // Actions + load: () => Promise + setAgent: (name: string) => void + setModel: (selection: ModelSelection | null) => void + cycleAgent: (direction?: 1 | -1) => void +} + +export const useCatalog = create((set, get) => ({ + agents: [], + commands: [], + providers: [], + defaults: {}, + agent: "", + model: null, + loaded: false, + + load: async () => { + const client = useConnections.getState().client + if (!client) return + + const [agentResult, commandResult, providerResult] = await Promise.all([ + client.agent.list().catch(() => [] as Agent[]), + client.command.list().catch(() => [] as Command[]), + client.provider.list().catch(() => null), + ]) + + const agents = Array.isArray(agentResult) ? agentResult : [] + const commands = Array.isArray(commandResult) ? commandResult : [] + + // Parse provider response: { all: [...], default: {...}, connected: [...] } + const raw = providerResult + const connected = new Set(Array.isArray(raw?.connected) ? raw.connected : []) + const defaults = raw?.default || {} + const providers: Provider[] = Array.isArray(raw?.all) + ? raw.all + .filter((p) => connected.has(p.id)) + .map((p) => ({ + id: p.id, + name: p.name || p.id, + connected: connected.has(p.id), + models: Object.values(p.models || {}) + .filter((m) => m.status !== "deprecated") + .map((m) => ({ + id: m.id, + name: m.name || m.id, + reasoning: m.reasoning ?? false, + attachment: m.attachment ?? false, + limit: m.limit, + })), + })) + .filter((p) => p.models.length > 0) + : [] + + console.log( + "[catalog] loaded:", + agents.length, + "agents,", + commands.length, + "commands,", + providers.length, + "providers (" + providers.reduce((n, p) => n + p.models.length, 0) + " models)", + ) + + // Filter out hidden agents + const visible = agents.filter((a) => !a.hidden) + + // Default agent + const current = get().agent + const agent = current && visible.some((a) => a.name === current) ? current : visible[0]?.name || "build" + + // Default model: use default agent's model, or first connected provider's default model + const existing = get().model + const fallback = (() => { + const defaultAgent = visible[0] + if (defaultAgent?.model) return defaultAgent.model + for (const p of providers) { + const defaultModelID = defaults[p.id] + const match = defaultModelID ? p.models.find((m) => m.id === defaultModelID) : p.models[0] + if (match) return { providerID: p.id, modelID: match.id } + } + return null + })() + const model = existing || fallback + + set({ agents: visible, commands, providers, defaults, agent, model, loaded: true }) + }, + + setAgent: (name) => { + const match = get().agents.find((a) => a.name === name) + if (!match) return + const model = match.model || get().model + set({ agent: name, model }) + }, + + setModel: (selection) => set({ model: selection }), + + cycleAgent: (direction = 1) => { + const { agents, agent } = get() + const primary = agents.filter((a) => a.mode === "primary" || a.mode === "all") + if (primary.length < 2) return + const idx = primary.findIndex((a) => a.name === agent) + const next = (idx + direction + primary.length) % primary.length + get().setAgent(primary[next].name) + }, +})) diff --git a/src/stores/connections.ts b/src/stores/connections.ts new file mode 100644 index 0000000..4d784ba --- /dev/null +++ b/src/stores/connections.ts @@ -0,0 +1,312 @@ +import { create } from "zustand" +import * as SecureStore from "expo-secure-store" +import type { ServerConnection, ConnectionType } from "../lib/types" +import { createClient, type Client, type Project } from "../lib/sdk" + +const CONNECTIONS_KEY = "opencode_connections" +const PASSWORDS_PREFIX = "opencode_password_" +const RECENT_DIRS_KEY = "opencode_recent_dirs" +const MAX_RECENT_DIRS = 10 + +// Cached auth so we can create directory-scoped clients without async SecureStore lookups +interface ClientBase { + baseUrl: string + auth?: { username: string; password: string } +} + +interface ConnectionsState { + connections: ServerConnection[] + activeConnection: ServerConnection | null + client: Client | null + clientBase: ClientBase | null + currentProject: Project | null + serverHome: string | null // Home directory on the server machine (for ~ expansion) + recentDirectories: string[] + isLoading: boolean + error: string | null + + // Actions + loadConnections: () => Promise + addConnection: (connection: Omit, password?: string) => Promise + removeConnection: (id: string) => Promise + setActiveConnection: (id: string) => Promise + testConnection: (connection: ServerConnection, password?: string) => Promise + updateConnection: (id: string, updates: Partial) => Promise + refreshProject: () => Promise + // Create a one-off client pointing at a specific directory (for cross-project operations) + clientForDirectory: (directory: string) => Client | null + // Switch the active connection's directory and reload + switchDirectory: (directory?: string) => Promise + // Record a directory as recently used + addRecentDirectory: (directory: string) => Promise +} + +function generateId(): string { + return Math.random().toString(36).slice(2, 11) +} + +function buildClient( + url: string, + directory?: string, + auth?: { username: string; password: string }, +): { client: Client; base: ClientBase } { + const base: ClientBase = { baseUrl: url, auth } + const client = createClient({ baseUrl: url, directory, auth }) + return { client, base } +} + +export const useConnections = create((set, get) => ({ + connections: [], + activeConnection: null, + client: null, + clientBase: null, + serverHome: null, + currentProject: null, + recentDirectories: [], + isLoading: true, + error: null, + + loadConnections: async () => { + try { + set({ isLoading: true, error: null }) + const [stored, recentRaw] = await Promise.all([ + SecureStore.getItemAsync(CONNECTIONS_KEY), + SecureStore.getItemAsync(RECENT_DIRS_KEY), + ]) + const connections: ServerConnection[] = stored ? JSON.parse(stored) : [] + const recentDirectories: string[] = recentRaw ? JSON.parse(recentRaw) : [] + + // Find active connection + const active = connections.find((c) => c.active) || null + + // Create client for active connection + let client: Client | null = null + let base: ClientBase | null = null + let project: Project | null = null + let home: string | null = null + if (active) { + const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`) + const auth = active.username && password ? { username: active.username, password } : undefined + const built = buildClient(active.url, active.directory, auth) + client = built.client + base = built.base + // Fetch current project info and server paths + try { + const [proj, paths] = await Promise.all([ + client.project.current().catch(() => null), + client.path.get().catch(() => null), + ]) + project = proj + home = paths?.home || null + } catch { + // Server might be offline + } + } + + set({ + connections, + activeConnection: active, + client, + clientBase: base, + currentProject: project, + serverHome: home, + recentDirectories, + isLoading: false, + }) + } catch (error) { + set({ error: "Failed to load connections", isLoading: false }) + } + }, + + addConnection: async (connection, password) => { + const id = generateId() + const newConnection: ServerConnection = { + ...connection, + id, + active: get().connections.length === 0, // First connection is active + } + + const connections = [...get().connections, newConnection] + + // Store password separately if provided + if (password) { + await SecureStore.setItemAsync(`${PASSWORDS_PREFIX}${id}`, password) + } + + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + + // If this is the first/active connection, create client + let client = get().client + let base = get().clientBase + let activeConnection = get().activeConnection + + if (newConnection.active) { + activeConnection = newConnection + const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined + const built = buildClient(newConnection.url, newConnection.directory, auth) + client = built.client + base = built.base + } + + set({ connections, activeConnection, client, clientBase: base }) + }, + + removeConnection: async (id) => { + const connections = get().connections.filter((c) => c.id !== id) + + // Remove stored password + await SecureStore.deleteItemAsync(`${PASSWORDS_PREFIX}${id}`) + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + + // If removing active connection, clear client + const wasActive = get().activeConnection?.id === id + if (wasActive) { + const newActive = connections[0] || null + if (newActive) { + // Mark new connection as active + newActive.active = true + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`) + const auth = newActive.username && password ? { username: newActive.username, password } : undefined + const built = buildClient(newActive.url, newActive.directory, auth) + set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base }) + } else { + set({ connections, activeConnection: null, client: null, clientBase: null }) + } + } else { + set({ connections }) + } + }, + + setActiveConnection: async (id) => { + const connections = get().connections.map((c) => ({ + ...c, + active: c.id === id, + })) + + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + + const active = connections.find((c) => c.id === id) || null + let client: Client | null = null + let base: ClientBase | null = null + let project: Project | null = null + let home: string | null = null + + if (active) { + const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`) + const auth = active.username && password ? { username: active.username, password } : undefined + const built = buildClient(active.url, active.directory, auth) + client = built.client + base = built.base + + try { + const [proj, paths] = await Promise.all([ + client.project.current().catch(() => null), + client.path.get().catch(() => null), + ]) + project = proj + home = paths?.home || null + } catch { + // Server might be offline + } + + // Update last connected time + active.lastConnected = Date.now() + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + } + + set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home }) + }, + + testConnection: async (connection, password) => { + try { + const client = createClient({ + baseUrl: connection.url, + directory: connection.directory, + auth: connection.username && password ? { username: connection.username, password } : undefined, + }) + + await client.global.health() + return true + } catch { + return false + } + }, + + updateConnection: async (id, updates) => { + const connections = get().connections.map((c) => (c.id === id ? { ...c, ...updates } : c)) + + await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) + + // If updating active connection, recreate client + if (get().activeConnection?.id === id) { + const active = connections.find((c) => c.id === id)! + const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`) + const auth = active.username && password ? { username: active.username, password } : undefined + const built = buildClient(active.url, active.directory, auth) + try { + const [project, paths] = await Promise.all([ + built.client.project.current().catch(() => null), + built.client.path.get().catch(() => null), + ]) + set({ + connections, + activeConnection: active, + client: built.client, + clientBase: built.base, + currentProject: project, + serverHome: paths?.home || null, + }) + } catch { + set({ + connections, + activeConnection: active, + client: built.client, + clientBase: built.base, + currentProject: null, + }) + } + } else { + set({ connections }) + } + }, + + refreshProject: async () => { + const client = get().client + if (!client) return + + try { + const project = await client.project.current() + set({ currentProject: project }) + } catch { + set({ currentProject: null }) + } + }, + + clientForDirectory: (directory) => { + const base = get().clientBase + if (!base) return null + // Reuse current client if directory matches + const active = get().activeConnection + if (active?.directory === directory) return get().client + return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth }) + }, + + switchDirectory: async (directory) => { + const active = get().activeConnection + if (!active) return + // Update connection directory and recreate client + const dir = directory?.trim() || undefined + await get().updateConnection(active.id, { directory: dir }) + // Record in recents if it's a real directory + if (dir) await get().addRecentDirectory(dir) + }, + + addRecentDirectory: async (directory) => { + const current = get().recentDirectories + // Move to front, dedup, cap at MAX + const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS) + set({ recentDirectories: updated }) + await SecureStore.setItemAsync(RECENT_DIRS_KEY, JSON.stringify(updated)) + }, +})) diff --git a/src/stores/events.ts b/src/stores/events.ts new file mode 100644 index 0000000..bdbba46 --- /dev/null +++ b/src/stores/events.ts @@ -0,0 +1,371 @@ +import { create } from "zustand" +import { useConnections } from "./connections" +import { useSessions } from "./sessions" +import { send as notify } from "../lib/notifications" +import type { Client, Part, Session, Message } from "../lib/sdk" + +// Session status from the server +type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string } + +// Tool status labels derived from part type +const TOOL_STATUS: Record = { + read: "Gathering context...", + list: "Searching codebase...", + grep: "Searching codebase...", + glob: "Searching codebase...", + webfetch: "Searching web...", + edit: "Making edits...", + write: "Making edits...", + apply_patch: "Making edits...", + bash: "Running command...", + task: "Delegating...", + todowrite: "Planning...", + todoread: "Planning...", +} + +function statusFromPart(part: Part): string { + if (part.type === "reasoning") return "Thinking..." + if (part.type === "tool" && part.tool) return TOOL_STATUS[part.tool] || `Running ${part.tool}...` + if (part.type === "text") return "Writing..." + return "Working..." +} + +interface EventsState { + connected: boolean + reconnectAttempts: number + lastDisconnectAt: number | null + sessionStatus: Record + statusText: Record + // Permissions & questions (pending per session) + permissions: Record< + string, + Array<{ + id: string + sessionID: string + permission: string + patterns: string[] + metadata: Record + tool?: { messageID: string; callID: string } + }> + > + questions: Record< + string, + Array<{ + id: string + sessionID: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiple?: boolean + custom?: boolean + }> + tool?: { messageID: string; callID: string } + }> + > + + connect: () => void + disconnect: () => void +} + +let controller: AbortController | null = null +let reconnectTimer: ReturnType | null = null + +const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const +const STABLE_CONNECTION_MS = 10_000 +const PROLONGED_DISCONNECT_MS = 30_000 + +// Re-fetch pending permissions and questions from the server for a session. +// Called when entering a session to recover from missed SSE events or failed +// optimistic removals. +export async function refreshPending(client: Client, sessionID: string) { + try { + const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()]) + const sessionPerms = (perms || []).filter((p: Record) => p.sessionID === sessionID) + const sessionQuestions = (questions || []).filter((q: Record) => q.sessionID === sessionID) + useEvents.setState((state) => ({ + permissions: { ...state.permissions, [sessionID]: sessionPerms as any }, + questions: { ...state.questions, [sessionID]: sessionQuestions as any }, + })) + } catch (err) { + console.warn("[Events] Failed to refresh pending:", err) + } +} + +export const useEvents = create((set, get) => ({ + connected: false, + reconnectAttempts: 0, + lastDisconnectAt: null, + sessionStatus: {}, + statusText: {}, + permissions: {}, + questions: {}, + + connect: () => { + controller?.abort() + controller = null + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const client = useConnections.getState().client + if (!client) return + + controller = new AbortController() + const currentController = controller + set({ connected: true }) + console.log("[SSE] Connecting to event stream...") + + // Run in background + ;(async () => { + let reconnectScheduled = false + const stableTimer = setTimeout(() => { + if (!currentController.signal.aborted) { + set({ reconnectAttempts: 0, lastDisconnectAt: null }) + } + }, STABLE_CONNECTION_MS) + + const scheduleReconnect = (reason: unknown) => { + if (reconnectScheduled || currentController.signal.aborted) return + reconnectScheduled = true + const state = get() + const reconnectAttempts = state.reconnectAttempts + 1 + const lastDisconnectAt = state.lastDisconnectAt ?? Date.now() + const disconnectedFor = Date.now() - lastDisconnectAt + set({ connected: false, reconnectAttempts, lastDisconnectAt }) + + if (disconnectedFor >= PROLONGED_DISCONNECT_MS) { + notify({ + category: "connection", + title: "Connection interrupted", + body: "Trying to reconnect to your server", + sessionId: "", + dedupeKey: "sse-prolonged-disconnect", + dedupeCooldownMs: 60_000, + }) + } + + const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)] + const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5))) + console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason) + reconnectTimer = setTimeout(() => { + reconnectTimer = null + get().connect() + }, jitteredDelay) + } + + try { + for await (const event of client.global.events(currentController.signal)) { + if (currentController.signal.aborted) break + + const payload = (event as any).payload || event + const type = payload.type as string + const props = payload.properties || {} + + switch (type) { + case "session.status": { + const sessionID = props.sessionID as string + const status = props.status as SessionStatus + if (!sessionID) break + + // Detect busy → idle transition for completion notification + const previous = get().sessionStatus[sessionID] + const completed = previous?.type === "busy" && status.type === "idle" + + set((state) => ({ + sessionStatus: { ...state.sessionStatus, [sessionID]: status }, + // Clear status text when idle + statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText, + })) + + // SSE is the source of truth — update sending state unconditionally + if (status.type === "idle") { + useSessions.setState((state) => ({ + sending: { ...state.sending, [sessionID]: false }, + })) + // Refresh messages if this is the session the user is viewing + const sessions = useSessions.getState() + if (sessions.currentSession?.id === sessionID) { + sessions.refreshMessages() + } + } + + if (completed) { + const match = useSessions.getState().sessions.find((s) => s.id === sessionID) + notify({ + category: "completed", + title: "Task completed", + body: match?.title || "Session finished processing", + sessionId: sessionID, + }) + } + break + } + + case "message.updated": { + const info = props.info as Message | undefined + if (!info) break + useSessions.getState().handleEvent({ type, properties: { info } } as any) + break + } + + case "message.part.updated": { + const part = props.part as Part | undefined + if (!part) break + + // Update status text from the latest part + const sessionID = (part as any).sessionID as string + if (sessionID) { + set((state) => ({ + statusText: { ...state.statusText, [sessionID]: statusFromPart(part) }, + })) + } + + useSessions.getState().handleEvent({ type, properties: { part } } as any) + break + } + + case "session.updated": { + const info = props.info as Session | undefined + if (!info) break + useSessions.getState().handleEvent({ type, properties: { info } } as any) + break + } + + case "session.created": { + const info = props.info as Session | undefined + if (!info) break + // Add to sessions list + useSessions.setState((state) => { + const exists = state.sessions.some((s) => s.id === info.id) + if (exists) return {} + return { sessions: [info, ...state.sessions] } + }) + break + } + + case "session.error": { + const error = props.error as { message?: string } | undefined + const sessionID = props.sessionID as string + if (!sessionID) break + // Clear sending state unconditionally — SSE is truth + useSessions.setState((state) => ({ + sending: { ...state.sending, [sessionID]: false }, + // Surface error only if user is viewing this session + ...(state.currentSession?.id === sessionID + ? { error: error?.message || "Session error occurred" } + : {}), + })) + if (useSessions.getState().currentSession?.id === sessionID) { + useSessions.getState().refreshMessages() + } + notify({ + category: "errors", + title: "Session error", + body: error?.message || "Something went wrong", + sessionId: sessionID, + }) + break + } + + case "permission.asked": { + const req = props as any + if (!req.id || !req.sessionID) break + const existing = get().permissions[req.sessionID] || [] + if (existing.some((item) => item.id === req.id)) break + set((state) => ({ + permissions: { + ...state.permissions, + [req.sessionID]: [...(state.permissions[req.sessionID] || []), req], + }, + })) + notify({ + category: "permissions", + title: req.permission || "Permission requested", + body: req.patterns?.join(", ") || "A tool needs your approval", + sessionId: req.sessionID, + }) + break + } + + case "permission.replied": { + const sessionID = props.sessionID as string + const requestID = props.requestID as string + if (!sessionID || !requestID) break + set((state) => ({ + permissions: { + ...state.permissions, + [sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID), + }, + })) + break + } + + case "question.asked": { + const req = props as any + if (!req.id || !req.sessionID) break + const existing = get().questions[req.sessionID] || [] + if (existing.some((item) => item.id === req.id)) break + set((state) => ({ + questions: { + ...state.questions, + [req.sessionID]: [...(state.questions[req.sessionID] || []), req], + }, + })) + notify({ + category: "questions", + title: req.questions?.[0]?.header || "Input needed", + body: req.questions?.[0]?.question || "The assistant has a question", + sessionId: req.sessionID, + }) + break + } + + case "question.replied": + case "question.rejected": { + const sessionID = props.sessionID as string + const requestID = props.requestID as string + if (!sessionID || !requestID) break + set((state) => ({ + questions: { + ...state.questions, + [sessionID]: (state.questions[sessionID] || []).filter((q) => q.id !== requestID), + }, + })) + break + } + } + } + + scheduleReconnect(new Error("Event stream closed")) + } catch (err) { + scheduleReconnect(err) + } finally { + clearTimeout(stableTimer) + if (currentController.signal.aborted) { + console.log("[SSE] Disconnected (aborted)") + } + } + })() + }, + + disconnect: () => { + console.log("[SSE] Disconnecting") + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + controller?.abort() + controller = null + set({ + connected: false, + reconnectAttempts: 0, + lastDisconnectAt: null, + sessionStatus: {}, + statusText: {}, + permissions: {}, + questions: {}, + }) + }, +})) diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts new file mode 100644 index 0000000..1019129 --- /dev/null +++ b/src/stores/sessions.ts @@ -0,0 +1,374 @@ +import { create } from "zustand" +import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk" +import { useConnections } from "./connections" +import { useSettings } from "./settings" + +// Helper to convert API response to our internal format +function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record } { + const messages: Message[] = [] + const parts: Record = {} + + for (const item of response || []) { + messages.push(item.info) + parts[item.info.id] = item.parts || [] + } + + return { messages, parts } +} + +function pageSize(): number { + return useSettings.getState().pageSize +} + +interface SessionsState { + sessions: Session[] + currentSession: Session | null + messages: Message[] + parts: Record + isLoading: boolean + // Per-session optimistic sending flag — bridging gap between user tap and SSE busy + sending: Record + loadingMore: boolean + hasMore: boolean + error: string | null + + // Actions + loadSessions: () => Promise + selectSession: (sessionID: string, directory?: string) => Promise + loadOlderMessages: () => Promise + createSession: (title?: string) => Promise + deleteSession: (sessionID: string) => Promise + sendMessage: ( + text: string, + model?: { providerID: string; modelID: string }, + agent?: string, + files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>, + ) => Promise + abortSession: () => Promise + refreshMessages: () => Promise + + // Event handling + handleEvent: (event: Event) => void +} + +// Get the right client for a session's directory +function clientFor(directory?: string): Client | null { + const connState = useConnections.getState() + if (!directory) return connState.client + const connDir = connState.activeConnection?.directory + if (directory !== connDir) return connState.clientForDirectory(directory) + return connState.client +} + +export const useSessions = create((set, get) => ({ + sessions: [], + currentSession: null, + messages: [], + parts: {}, + isLoading: false, + sending: {}, + loadingMore: false, + hasMore: false, + error: null, + + loadSessions: async () => { + const client = useConnections.getState().client + if (!client) { + set({ error: "No active connection" }) + return + } + + try { + set({ isLoading: true, error: null }) + const sessions = await client.session.list({ roots: true, limit: 50 }) + set({ sessions, isLoading: false }) + } catch (error) { + set({ error: "Failed to load sessions", isLoading: false }) + } + }, + + selectSession: async (sessionID, directory) => { + // Use directory-specific client if the session belongs to a different project + const connState = useConnections.getState() + const client = directory ? connState.clientForDirectory(directory) : connState.client + if (!client) { + set({ error: "No active connection" }) + return + } + + try { + // Reset optimistic sending — SSE sessionStatus is the source of truth + set((state) => ({ + isLoading: true, + error: null, + hasMore: false, + loadingMore: false, + sending: { ...state.sending, [sessionID]: false }, + })) + + const [session, messagesResponse] = await Promise.all([ + client.session.get(sessionID), + client.session.messages(sessionID, { limit: pageSize() }), + ]) + + // Parse the API response format: array of { info, parts } + const { messages, parts } = parseMessages(messagesResponse) + + set({ + currentSession: session, + messages, + parts, + isLoading: false, + // If we got exactly PAGE_SIZE messages, there are probably more + hasMore: messagesResponse.length >= pageSize(), + }) + } catch (err) { + console.error("Failed to load session:", err) + set({ error: "Failed to load session", isLoading: false }) + } + }, + + loadOlderMessages: async () => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) return + if (get().loadingMore || !get().hasMore) return + + try { + set({ loadingMore: true }) + + // Fetch ALL messages for this session + const response = await client.session.messages(session.id) + const { messages: all, parts: allParts } = parseMessages(response) + + // Merge: use all messages from full fetch, but keep any temp/optimistic messages + const existing = get().messages + const temp = existing.filter((m) => m.id.startsWith("temp-")) + const merged = [...all, ...temp] + + set({ + messages: merged, + parts: { ...allParts, ...Object.fromEntries(temp.map((m) => [m.id, get().parts[m.id] || []])) }, + loadingMore: false, + hasMore: false, // We loaded everything + }) + } catch (error) { + console.error("Failed to load older messages:", error) + set({ loadingMore: false }) + } + }, + + createSession: async (title) => { + const client = useConnections.getState().client + if (!client) { + set({ error: "No active connection" }) + return null + } + + try { + const session = await client.session.create({ title }) + // Don't optimistically add to sessions list — let loadSessions() handle it + // to avoid duplicate key errors from race conditions + set({ + currentSession: session, + messages: [], + parts: {}, + hasMore: false, + loadingMore: false, + }) + return session + } catch (error) { + set({ error: "Failed to create session" }) + return null + } + }, + + deleteSession: async (sessionID) => { + const client = useConnections.getState().client + if (!client) { + set({ error: "No active connection" }) + return + } + + try { + await client.session.delete(sessionID) + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== sessionID), + currentSession: state.currentSession?.id === sessionID ? null : state.currentSession, + messages: state.currentSession?.id === sessionID ? [] : state.messages, + parts: state.currentSession?.id === sessionID ? {} : state.parts, + })) + } catch (error) { + set({ error: "Failed to delete session" }) + } + }, + + sendMessage: async (text, model, agent, files) => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) { + set({ error: "No active session" }) + return + } + + try { + set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null })) + + // Add user message optimistically + const ts = Date.now() + const userMessage: Message = { + id: `temp-${ts}`, + sessionID: session.id, + role: "user", + time: { created: ts }, + model, + agent, + } + const optimisticParts: Part[] = [] + if (text) { + optimisticParts.push({ + id: `temp-part-text-${ts}`, + messageID: userMessage.id, + type: "text", + text, + }) + } + if (files) { + for (let i = 0; i < files.length; i++) { + const f = files[i] + optimisticParts.push({ + id: `temp-part-file-${ts}-${i}`, + messageID: userMessage.id, + type: "file", + mime: f.mime, + url: f.uri, + filename: f.filename, + }) + } + } + + set((state) => ({ + messages: [...state.messages, userMessage], + parts: { ...state.parts, [userMessage.id]: optimisticParts }, + })) + + // Build prompt parts - images are already converted to JPEG with base64 by toJpeg() + const promptParts: Array< + { type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string } + > = [] + if (text) { + promptParts.push({ type: "text", text }) + } + if (files) { + for (const f of files) { + const url = f.base64 ? `data:${f.mime};base64,${f.base64}` : f.uri + promptParts.push({ type: "file", mime: f.mime, url, filename: f.filename }) + } + } + + // Fire and forget - SSE events will update messages/parts/status in real-time + client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => { + console.error("Failed to send message:", err) + set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } })) + get().refreshMessages() + }) + } catch (err) { + console.error("[sendMessage] error:", err) + set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } })) + get().refreshMessages() + } + }, + + abortSession: async () => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) return + + try { + await client.session.abort(session.id) + set((state) => ({ sending: { ...state.sending, [session.id]: false } })) + } catch { + set({ error: "Failed to abort session" }) + } + }, + + refreshMessages: async () => { + const client = clientFor(get().currentSession?.directory) + const session = get().currentSession + if (!client || !session) return + + try { + const response = await client.session.messages(session.id) + const { messages, parts } = parseMessages(response) + set({ messages, parts }) + } catch (error) { + set({ error: "Failed to refresh messages" }) + } + }, + + handleEvent: (event) => { + const { currentSession } = get() + if (!currentSession) return + + const props = (event as any).properties || {} + + switch (event.type) { + case "message.updated": { + const message = (props.info || props.message) as Message | undefined + if (!message || message.sessionID !== currentSession.id) return + + set((state) => { + // Remove temp messages when real ones arrive + const filtered = state.messages.filter((m) => !m.id.startsWith("temp-") || m.id === message.id) + const exists = filtered.some((m) => m.id === message.id) + return { + messages: exists ? filtered.map((m) => (m.id === message.id ? message : m)) : [...filtered, message], + } + }) + break + } + + case "message.part.updated": { + const part = props.part as Part | undefined + if (!part) return + // Only handle parts for current session + if (part.sessionID && part.sessionID !== currentSession.id) return + + set((state) => { + const messageParts = state.parts[part.messageID] || [] + const exists = messageParts.some((p) => p.id === part.id) + return { + parts: { + ...state.parts, + [part.messageID]: exists + ? messageParts.map((p) => (p.id === part.id ? part : p)) + : [...messageParts, part], + }, + } + }) + break + } + + case "message.removed": { + const messageID = props.messageID as string + if (!messageID) return + set((state) => ({ + messages: state.messages.filter((m) => m.id !== messageID), + parts: Object.fromEntries(Object.entries(state.parts).filter(([k]) => k !== messageID)), + })) + break + } + + case "session.updated": { + const session = (props.info || props) as Session | undefined + if (!session?.id) return + + set((state) => ({ + sessions: state.sessions.map((s) => (s.id === session.id ? session : s)), + currentSession: state.currentSession?.id === session.id ? session : state.currentSession, + })) + break + } + } + }, +})) diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..b3bc3ed --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,59 @@ +import { create } from "zustand" +import * as SecureStore from "expo-secure-store" +import { type Category, defaultPreferences } from "../lib/notifications" + +const SETTINGS_KEY = "opencode_settings" + +interface Settings { + pageSize: number + notifications: Record +} + +const DEFAULTS: Settings = { + pageSize: 25, + notifications: { ...defaultPreferences }, +} + +interface SettingsState extends Settings { + loaded: boolean + load: () => Promise + setPageSize: (size: number) => Promise + setNotification: (category: Category, enabled: boolean) => Promise +} + +function snapshot(get: () => SettingsState): Settings { + return { pageSize: get().pageSize, notifications: get().notifications } +} + +async function persist(settings: Settings) { + await SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(settings)) +} + +export const useSettings = create((set, get) => ({ + ...DEFAULTS, + loaded: false, + + load: async () => { + const raw = await SecureStore.getItemAsync(SETTINGS_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Partial + // Merge stored notifications with defaults so new categories get their default + const notifications = { ...DEFAULTS.notifications, ...parsed.notifications } + set({ ...DEFAULTS, ...parsed, notifications, loaded: true }) + return + } + set({ loaded: true }) + }, + + setPageSize: async (size) => { + const clamped = Math.max(10, Math.min(200, size)) + set({ pageSize: clamped }) + await persist({ ...snapshot(get), pageSize: clamped }) + }, + + setNotification: async (category, enabled) => { + const notifications = { ...get().notifications, [category]: enabled } + set({ notifications }) + await persist({ ...snapshot(get), notifications }) + }, +})) diff --git a/src/types/expo-icons.d.ts b/src/types/expo-icons.d.ts new file mode 100644 index 0000000..10379c0 --- /dev/null +++ b/src/types/expo-icons.d.ts @@ -0,0 +1,31 @@ +// Override @expo/vector-icons types to fix React 18/19 JSX element type mismatch. +// The library bundles @types/react@18 internally which conflicts with our React 19. +// This declaration makes Ionicons (and other icon sets) work as valid JSX elements. +declare module "@expo/vector-icons" { + import type { TextProps } from "react-native" + + interface IconProps extends TextProps { + name: string + size?: number + color?: string + style?: any + } + + interface IconComponent extends React.FC { + glyphMap: Record + } + + export const Ionicons: IconComponent + export const MaterialIcons: IconComponent + export const MaterialCommunityIcons: IconComponent + export const FontAwesome: IconComponent + export const FontAwesome5: IconComponent + export const Feather: IconComponent + export const AntDesign: IconComponent + export const Entypo: IconComponent + export const EvilIcons: IconComponent + export const Foundation: IconComponent + export const Octicons: IconComponent + export const SimpleLineIcons: IconComponent + export const Zocial: IconComponent +} diff --git a/src/types/react-native-marked.d.ts b/src/types/react-native-marked.d.ts new file mode 100644 index 0000000..9a7d410 --- /dev/null +++ b/src/types/react-native-marked.d.ts @@ -0,0 +1,100 @@ +// Override react-native-marked types to fix React 18/19 ReactNode mismatch. +// The library bundles @types/react@18 internally which conflicts with our React 19. +// This declaration re-exports the module with React 19-compatible types. +declare module "react-native-marked" { + import type { ReactNode } from "react" + import type { FlatListProps, ImageStyle, TextStyle, ViewStyle } from "react-native" + import type { Hooks, Tokenizer } from "marked" + + export interface RendererInterface { + paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode + blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode + heading(text: string | ReactNode[], styles?: TextStyle, depth?: number): ReactNode + code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode + hr(styles?: ViewStyle): ReactNode + listItem(children: ReactNode[], styles?: ViewStyle): ReactNode + list( + ordered: boolean, + li: ReactNode[], + listStyle?: ViewStyle, + textStyle?: TextStyle, + startIndex?: number, + ): ReactNode + escape(text: string, styles?: TextStyle): ReactNode + link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode + image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode + strong(children: string | ReactNode[], styles?: TextStyle): ReactNode + em(children: string | ReactNode[], styles?: TextStyle): ReactNode + codespan(text: string, styles?: TextStyle): ReactNode + br(): ReactNode + del(children: string | ReactNode[], styles?: TextStyle): ReactNode + text(text: string | ReactNode[], styles?: TextStyle): ReactNode + html(text: string | ReactNode[], styles?: TextStyle): ReactNode + linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode + table( + header: ReactNode[][], + rows: ReactNode[][][], + tableStyle?: ViewStyle, + rowStyle?: ViewStyle, + cellStyle?: ViewStyle, + ): ReactNode + } + + export class Renderer implements RendererInterface { + paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode + blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode + heading(text: string | ReactNode[], styles?: TextStyle): ReactNode + code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode + hr(styles?: ViewStyle): ReactNode + listItem(children: ReactNode[], styles?: ViewStyle): ReactNode + list( + ordered: boolean, + li: ReactNode[], + listStyle?: ViewStyle, + textStyle?: TextStyle, + startIndex?: number, + ): ReactNode + escape(text: string, styles?: TextStyle): ReactNode + link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode + image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode + strong(children: string | ReactNode[], styles?: TextStyle): ReactNode + em(children: string | ReactNode[], styles?: TextStyle): ReactNode + codespan(text: string, styles?: TextStyle): ReactNode + br(): ReactNode + del(children: string | ReactNode[], styles?: TextStyle): ReactNode + text(text: string | ReactNode[], styles?: TextStyle): ReactNode + html(text: string | ReactNode[], styles?: TextStyle): ReactNode + linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode + table( + header: ReactNode[][], + rows: ReactNode[][][], + tableStyle?: ViewStyle, + rowStyle?: ViewStyle, + cellStyle?: ViewStyle, + ): ReactNode + getKey(): string + } + + export interface UserTheme { + colors?: Record + spacing?: Record + } + + export interface MarkedStyles { + [key: string]: ViewStyle | TextStyle | ImageStyle | undefined + } + + export interface MarkdownProps { + value: string + flatListProps?: Omit, "data" | "renderItem" | "horizontal"> + theme?: UserTheme + renderer?: RendererInterface + styles?: MarkedStyles | Record + baseUrl?: string + tokenizer?: Tokenizer + hooks?: Hooks + } + + const Markdown: React.FC + export default Markdown +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ec96c84 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +}