Initial commit: OpenCode mobile app (extracted from opencode monorepo)
59
.github/workflows/build.yml
vendored
Normal file
@@ -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
|
||||||
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
android/.gradle/
|
||||||
|
android/app/build/
|
||||||
|
android/build/
|
||||||
|
ios/Pods/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
59
AGENTS.md
Normal file
@@ -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.
|
||||||
88
README.md
Normal file
@@ -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.
|
||||||
16
android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
182
android/app/build.gradle
Normal file
@@ -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..<options.size()) options[i] = options[i].trim();
|
||||||
|
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||||
|
options -= ""
|
||||||
|
|
||||||
|
if (options.length > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
android/app/proguard-rules.pro
vendored
Normal file
@@ -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:
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
7
android/app/src/debugOptimized/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
34
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="opencode"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
61
android/app/src/main/java/ai/opencode/mobile/MainActivity.kt
Normal file
@@ -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 <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReactPackage> =
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
http://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.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||||
|
>
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<resources/>
|
||||||
5
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splashscreen_background">#FFFFFF</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
<color name="colorPrimaryDark">#ffffff</color>
|
||||||
|
</resources>
|
||||||
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">OpenCode</string>
|
||||||
|
</resources>
|
||||||
11
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||||
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="android:statusBarColor">#ffffff</item>
|
||||||
|
</style>
|
||||||
|
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||||
|
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
24
android/build.gradle
Normal file
@@ -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"
|
||||||
65
android/gradle.properties
Normal file
@@ -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 <task> -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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
251
android/gradlew
vendored
Executable file
@@ -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" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
@@ -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
|
||||||
39
android/settings.gradle
Normal file
@@ -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)
|
||||||
29
app.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/(tabs)/_layout.tsx
Normal file
@@ -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 (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||||
|
tabBarInactiveTintColor: isDark ? "#666666" : "#999999",
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||||
|
borderTopColor: isDark ? "#1a1a1a" : "#e5e5e5",
|
||||||
|
},
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||||
|
},
|
||||||
|
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: "Sessions",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="connections"
|
||||||
|
options={{
|
||||||
|
title: "Connections",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: "Settings",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
387
app/(tabs)/connections.tsx
Normal file
@@ -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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.connectionItem,
|
||||||
|
isDark && styles.connectionItemDark,
|
||||||
|
isActive && styles.connectionItemActive,
|
||||||
|
isActive && isDark && styles.connectionItemActiveDark,
|
||||||
|
]}
|
||||||
|
onPress={onSelect}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
>
|
||||||
|
<View style={styles.connectionIcon}>
|
||||||
|
<Ionicons name={typeIcon} size={24} color={isActive ? "#22c55e" : isDark ? "#888888" : "#666666"} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.connectionContent}>
|
||||||
|
<View style={styles.connectionHeader}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.connectionName,
|
||||||
|
isDark && styles.textDark,
|
||||||
|
isActive && styles.connectionNameActive,
|
||||||
|
isActive && isDark && styles.connectionNameActiveDark,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{connection.name}
|
||||||
|
</Text>
|
||||||
|
{isActive && (
|
||||||
|
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
|
||||||
|
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[styles.connectionUrl, isDark && styles.metaDark, isActive && styles.connectionUrlActive]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{connection.url}
|
||||||
|
</Text>
|
||||||
|
{connection.lastConnected && (
|
||||||
|
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
|
||||||
|
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={onEdit} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||||
|
<Ionicons name="ellipsis-vertical" size={20} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||||
|
<FlatList
|
||||||
|
data={connections}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ConnectionItem
|
||||||
|
connection={item}
|
||||||
|
isDark={isDark}
|
||||||
|
isActive={activeConnection?.id === item.id}
|
||||||
|
onSelect={() => setActiveConnection(item.id)}
|
||||||
|
onEdit={() => router.push(`/connection/${item.id}`)}
|
||||||
|
onDelete={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
|
||||||
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||||
|
Add a connection to your OpenCode server
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View style={[styles.header, isDark && styles.headerDark]}>
|
||||||
|
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
||||||
|
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
|
||||||
|
<View style={styles.settingRow}>
|
||||||
|
<View style={styles.settingLabel}>
|
||||||
|
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pagePicker}>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={size}
|
||||||
|
style={[
|
||||||
|
styles.pageOption,
|
||||||
|
isDark && styles.pageOptionDark,
|
||||||
|
pageSize === size && styles.pageOptionActive,
|
||||||
|
pageSize === size && isDark && styles.pageOptionActiveDark,
|
||||||
|
]}
|
||||||
|
onPress={() => setPageSize(size)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.pageOptionText,
|
||||||
|
isDark && styles.metaDark,
|
||||||
|
pageSize === size && styles.pageOptionTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
|
||||||
|
How many messages to load when opening a session. Lower = faster.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FAB to add connection */}
|
||||||
|
<TouchableOpacity style={[styles.fab, isDark && styles.fabDark]} onPress={() => router.push("/connection/add")}>
|
||||||
|
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
836
app/(tabs)/index.tsx
Normal file
@@ -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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
>
|
||||||
|
<View style={styles.sessionContent}>
|
||||||
|
<View style={styles.sessionHeader}>
|
||||||
|
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
||||||
|
{session.title || "Untitled Session"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.sessionMetaRow}>
|
||||||
|
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
||||||
|
{formatTime(session.time.updated)}
|
||||||
|
{session.summary && ` · ${session.summary.files} files`}
|
||||||
|
</Text>
|
||||||
|
{shortDir && (
|
||||||
|
<View style={styles.sessionDirBadge}>
|
||||||
|
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Session | null>(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<BottomSheet>(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 (
|
||||||
|
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||||
|
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
|
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
|
||||||
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||||
|
onPress={() => router.push("/connection/add")}
|
||||||
|
>
|
||||||
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortPath = getShortPath(currentProject)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||||
|
{/* Connection indicator — tap to switch project */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
|
||||||
|
onPress={() => dirSheetRef.current?.expand()}
|
||||||
|
onLongPress={() => router.push("/(tabs)/connections")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.connectionInfo}>
|
||||||
|
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} />
|
||||||
|
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
|
||||||
|
{activeConnection.name}
|
||||||
|
</Text>
|
||||||
|
{shortPath && (
|
||||||
|
<>
|
||||||
|
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
|
||||||
|
{shortPath}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<View style={styles.errorBar}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<SessionItem
|
||||||
|
session={item}
|
||||||
|
isDark={isDark}
|
||||||
|
onRename={() => handleRename(item)}
|
||||||
|
onDelete={() => handleDelete(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyList}>
|
||||||
|
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FAB to create new session */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.fab, isDark && styles.fabDark]}
|
||||||
|
onPress={onFabPress}
|
||||||
|
onLongPress={onFabLongPress}
|
||||||
|
delayLongPress={500}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* New Session Info Modal */}
|
||||||
|
<Modal visible={showNewSession} animationType="slide" transparent>
|
||||||
|
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||||
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
||||||
|
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalBody}>
|
||||||
|
{/* Current directory */}
|
||||||
|
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Directory</Text>
|
||||||
|
<View style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}>
|
||||||
|
<Ionicons name="folder" size={20} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
||||||
|
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Custom directory input */}
|
||||||
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||||
|
Or use a different folder
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||||
|
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={customDir}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
// 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 && (
|
||||||
|
<View style={styles.pathChips}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
||||||
|
onPress={() => setCustomDir(serverHome)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.pathChip, isDark && styles.pathChipDark]}
|
||||||
|
onPress={() => setCustomDir(serverHome + "/")}
|
||||||
|
>
|
||||||
|
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
{customDir.trim() ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modalButton,
|
||||||
|
styles.modalButtonPrimary,
|
||||||
|
isDark && styles.modalButtonPrimaryDark,
|
||||||
|
styles.modalButtonFull,
|
||||||
|
]}
|
||||||
|
onPress={() => onCreateInDirectory(customDir)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||||
|
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modalButton,
|
||||||
|
styles.modalButtonPrimary,
|
||||||
|
isDark && styles.modalButtonPrimaryDark,
|
||||||
|
styles.modalButtonFull,
|
||||||
|
]}
|
||||||
|
onPress={() => onCreateInDirectory()}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||||
|
Create Session
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Rename modal */}
|
||||||
|
<Modal visible={!!renaming} animationType="fade" transparent>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.modalOverlay, { justifyContent: "center" }]}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||||
|
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
||||||
|
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||||
|
value={renameText}
|
||||||
|
onChangeText={setRenameText}
|
||||||
|
onSubmitEditing={submitRename}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoFocus
|
||||||
|
selectTextOnFocus
|
||||||
|
autoCapitalize="sentences"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<View style={styles.renameActions}>
|
||||||
|
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
||||||
|
<Text style={styles.renameBtnCancelText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
||||||
|
onPress={submitRename}
|
||||||
|
disabled={!renameText.trim()}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Directory switcher bottom sheet */}
|
||||||
|
<DirectorySwitcher
|
||||||
|
sheetRef={dirSheetRef}
|
||||||
|
current={activeConnection?.directory}
|
||||||
|
recents={recentDirectories}
|
||||||
|
serverHome={serverHome}
|
||||||
|
isDark={isDark}
|
||||||
|
onSwitch={handleSwitchDirectory}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
289
app/(tabs)/settings.tsx
Normal file
@@ -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 = (
|
||||||
|
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
||||||
|
<View style={[styles.settingIcon, isDark && styles.settingIconDark]}>
|
||||||
|
<Ionicons name={icon} size={22} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingContent}>
|
||||||
|
<Text style={[styles.settingLabel, isDark && styles.textDark]}>{label}</Text>
|
||||||
|
{description && <Text style={[styles.settingDescription, isDark && styles.metaDark]}>{description}</Text>}
|
||||||
|
</View>
|
||||||
|
{right}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={[styles.sectionTitle, isDark && styles.sectionTitleDark]}>{title}</Text>
|
||||||
|
<View style={[styles.sectionContent, isDark && styles.sectionContentDark]}>{children}</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean | null>(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 (
|
||||||
|
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
|
||||||
|
<SettingSection title="Security" isDark={isDark}>
|
||||||
|
<SettingRow
|
||||||
|
icon="finger-print"
|
||||||
|
label="Require Biometric to Open"
|
||||||
|
description={
|
||||||
|
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
|
||||||
|
}
|
||||||
|
isDark={isDark}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={settings.requireBiometric}
|
||||||
|
onValueChange={(value) => updateSettings({ requireBiometric: value })}
|
||||||
|
disabled={!hasBiometrics}
|
||||||
|
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="lock-closed"
|
||||||
|
label="Require Biometric to Send"
|
||||||
|
description="Authenticate before sending messages"
|
||||||
|
isDark={isDark}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={settings.requireBiometricForMessages}
|
||||||
|
onValueChange={(value) => updateSettings({ requireBiometricForMessages: value })}
|
||||||
|
disabled={!hasBiometrics || !settings.requireBiometric}
|
||||||
|
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{settings.requireBiometric && (
|
||||||
|
<SettingRow
|
||||||
|
icon="exit"
|
||||||
|
label="Lock App Now"
|
||||||
|
description="Require authentication to reopen"
|
||||||
|
isDark={isDark}
|
||||||
|
onPress={lock}
|
||||||
|
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection title="Notifications" isDark={isDark}>
|
||||||
|
{categories.map((category) => {
|
||||||
|
const meta = categoryMeta[category]
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
key={category}
|
||||||
|
icon={meta.icon as keyof typeof Ionicons.glyphMap}
|
||||||
|
label={meta.label}
|
||||||
|
description={meta.description}
|
||||||
|
isDark={isDark}
|
||||||
|
right={
|
||||||
|
<Switch
|
||||||
|
value={notifications[category]}
|
||||||
|
onValueChange={(value) => handleToggle(category, value)}
|
||||||
|
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{osGranted === false && (
|
||||||
|
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
|
||||||
|
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
|
||||||
|
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<SettingSection title="About" isDark={isDark}>
|
||||||
|
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
|
||||||
|
<SettingRow
|
||||||
|
icon="logo-github"
|
||||||
|
label="GitHub"
|
||||||
|
description="View source code"
|
||||||
|
isDark={isDark}
|
||||||
|
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
|
||||||
|
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="document-text"
|
||||||
|
label="Documentation"
|
||||||
|
description="Learn how to use OpenCode"
|
||||||
|
isDark={isDark}
|
||||||
|
onPress={() => Linking.openURL("https://opencode.ai/docs")}
|
||||||
|
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
|
||||||
|
/>
|
||||||
|
</SettingSection>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
|
||||||
|
<Text style={[styles.footerText, isDark && styles.metaDark]}>
|
||||||
|
Connect to your AI coding assistant from anywhere
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
120
app/_layout.tsx
Normal file
@@ -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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthGate>
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: {
|
||||||
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||||
|
},
|
||||||
|
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="session/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Session",
|
||||||
|
presentation: "card",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="connection/add"
|
||||||
|
options={{
|
||||||
|
title: "Add Connection",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="connection/[id]"
|
||||||
|
options={{
|
||||||
|
title: "Edit Connection",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style={isDark ? "light" : "dark"} />
|
||||||
|
</AuthGate>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
)
|
||||||
|
}
|
||||||
413
app/connection/[id].tsx
Normal file
@@ -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<ConnectionType>(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 (
|
||||||
|
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
|
||||||
|
<Text style={[styles.errorText, isDark && styles.textDark]}>Connection not found</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.container, isDark && styles.containerDark]}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Connection Type */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||||
|
<View style={styles.typeContainer}>
|
||||||
|
{CONNECTION_TYPES.map((t) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.type}
|
||||||
|
style={[
|
||||||
|
styles.typeOption,
|
||||||
|
isDark && styles.typeOptionDark,
|
||||||
|
type === t.type && styles.typeOptionSelected,
|
||||||
|
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||||
|
]}
|
||||||
|
onPress={() => setType(t.type)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={t.icon}
|
||||||
|
size={20}
|
||||||
|
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.typeLabel,
|
||||||
|
isDark && styles.textDark,
|
||||||
|
type === t.type && styles.typeLabelSelected,
|
||||||
|
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="My Server"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="http://192.168.1.100:4096"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Directory */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={directory}
|
||||||
|
onChangeText={setDirectory}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||||
|
Leave empty to use the server's current directory. Sessions will be created in this folder.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
|
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||||
|
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="admin"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Password (leave empty to keep existing)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.testButton, isDark && styles.testButtonDark]}
|
||||||
|
onPress={handleTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
<Text style={[styles.testButtonText, isDark && styles.textDark]}>Test Connection</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, isDark && styles.saveButtonDark]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>Save Changes</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
||||||
|
<Text style={styles.deleteButtonText}>Delete Connection</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
548
app/connection/add.tsx
Normal file
@@ -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<ConnectionType>("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 (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.container, isDark && styles.containerDark]}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.quickHeader}>
|
||||||
|
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
|
||||||
|
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
|
||||||
|
Enter your computer's IP address to connect
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* IP Address */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>IP Address</Text>
|
||||||
|
<View style={styles.ipRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={ip}
|
||||||
|
onChangeText={setIp}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.portInput, isDark && styles.inputDark]}
|
||||||
|
placeholder="4096"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={port}
|
||||||
|
onChangeText={setPort}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Optional name */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Name (optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="My Mac"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password if needed */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Password (if set on server)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="Leave empty if none"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Connect button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectButton, isDark && styles.connectButtonDark]}
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
disabled={isConnecting}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Connect</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
|
||||||
|
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
|
||||||
|
<Text style={[styles.helpText, isDark && styles.hintDark]}>
|
||||||
|
On your Mac, run:{"\n"}
|
||||||
|
<Text style={styles.code}>ipconfig getifaddr en0</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||||
|
Tailscale examples:{"\n"}
|
||||||
|
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||||
|
{"\n"}
|
||||||
|
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||||
|
Use <Text style={styles.code}>http://</Text> for local and Tailscale addresses unless TLS is configured,
|
||||||
|
then use <Text style={styles.code}>https://</Text>.
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||||
|
Make sure OpenCode is running:{"\n"}
|
||||||
|
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Advanced mode link */}
|
||||||
|
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
|
||||||
|
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
|
||||||
|
Advanced options (tunnels, cloud, auth)
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advanced mode - full options
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.container, isDark && styles.containerDark]}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
|
||||||
|
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Connection Type */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||||
|
<View style={styles.typeContainer}>
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t.type}
|
||||||
|
style={[
|
||||||
|
styles.typeOption,
|
||||||
|
isDark && styles.typeOptionDark,
|
||||||
|
type === t.type && styles.typeOptionSelected,
|
||||||
|
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||||
|
]}
|
||||||
|
onPress={() => setType(t.type)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={t.icon}
|
||||||
|
size={20}
|
||||||
|
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.typeLabel,
|
||||||
|
isDark && styles.textDark,
|
||||||
|
type === t.type && styles.typeLabelSelected,
|
||||||
|
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="My Server"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder={
|
||||||
|
type === "local"
|
||||||
|
? "http://192.168.1.100:4096"
|
||||||
|
: type === "tunnel"
|
||||||
|
? "https://your-tunnel.trycloudflare.com"
|
||||||
|
: "https://api.opencode.ai"
|
||||||
|
}
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={url}
|
||||||
|
onChangeText={setUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||||
|
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
|
||||||
|
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
|
||||||
|
only when TLS is configured.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Directory */}
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={directory}
|
||||||
|
onChangeText={setDirectory}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||||
|
Leave empty to use the server's current directory, or specify a path to work in a different folder.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Auth */}
|
||||||
|
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||||
|
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="admin"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, isDark && styles.inputDark]}
|
||||||
|
placeholder="password"
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.connectButton, isDark && styles.connectButtonDark, { marginTop: 32 }]}
|
||||||
|
onPress={handleAdvancedSave}
|
||||||
|
disabled={isConnecting}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Save Connection</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
817
app/session/[id].tsx
Normal file
@@ -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<FlatList>(null)
|
||||||
|
const modelSheetRef = useRef<BottomSheet>(null)
|
||||||
|
const [input, setInput] = useState("")
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
|
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<SlashCommand[]>(() => {
|
||||||
|
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<Attachment> {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: currentSession?.title || "Session",
|
||||||
|
headerRight: () => (
|
||||||
|
<View style={s.headerRight}>
|
||||||
|
{shortDir && (
|
||||||
|
<View style={[s.dirBadge, isDark && s.dirBadgeDark]}>
|
||||||
|
<Ionicons name="folder-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[s.dirText, isDark && s.dirTextDark]}>{shortDir}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity onPress={() => setShowInfo((v) => !v)} hitSlop={8}>
|
||||||
|
<Ionicons
|
||||||
|
name={showInfo ? "stats-chart" : "stats-chart-outline"}
|
||||||
|
size={20}
|
||||||
|
color={showInfo ? "#3b82f6" : isDark ? "#888888" : "#666666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[s.container, isDark && s.containerDark]}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
||||||
|
>
|
||||||
|
{/* Session info pulldown */}
|
||||||
|
<SessionInfo
|
||||||
|
session={currentSession}
|
||||||
|
messages={messages || []}
|
||||||
|
providers={providers}
|
||||||
|
visible={showInfo}
|
||||||
|
isDark={isDark}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingAll={loadingMore}
|
||||||
|
onLoadAll={() => {
|
||||||
|
if (hasMore && !loadingMore) loadOlderMessages()
|
||||||
|
}}
|
||||||
|
onScrollToTop={() => {
|
||||||
|
flatListRef.current?.scrollToEnd({ animated: true })
|
||||||
|
}}
|
||||||
|
onClose={() => setShowInfo(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={s.loading}>
|
||||||
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={s.listWrap}>
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={messageData}
|
||||||
|
inverted
|
||||||
|
keyExtractor={(item) => item.message.id}
|
||||||
|
renderItem={({ item }) => <MessageBubble message={item.message} parts={item.parts} isDark={isDark} />}
|
||||||
|
contentContainerStyle={s.messageList}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={100}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
// Prevent jump when older messages are prepended
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<View style={s.loadingMore}>
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={s.emptyInverted}>
|
||||||
|
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
||||||
|
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
|
||||||
|
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{showScrollButton && (
|
||||||
|
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
|
||||||
|
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{currentSession && <StatusIndicator sessionID={currentSession.id} isDark={isDark} />}
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
{permissions.map((perm) => (
|
||||||
|
<PermissionPrompt
|
||||||
|
key={perm.id}
|
||||||
|
permission={perm}
|
||||||
|
isDark={isDark}
|
||||||
|
onReply={(reply) => handlePermissionReply(perm.id, reply)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Questions */}
|
||||||
|
{questions.map((q) => (
|
||||||
|
<QuestionPrompt
|
||||||
|
key={q.id}
|
||||||
|
request={q}
|
||||||
|
isDark={isDark}
|
||||||
|
onReply={(answers) => handleQuestionReply(q.id, answers)}
|
||||||
|
onReject={() => handleQuestionReject(q.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Slash popover */}
|
||||||
|
{slashActive && (
|
||||||
|
<SlashPopover query={slashQuery} commands={allCommands} isDark={isDark} onSelect={handleSlashSelect} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent/model toolbar */}
|
||||||
|
<View style={[s.toolbar, isDark && s.toolbarDark]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.agentChip, { borderColor: agentColor }]}
|
||||||
|
onPress={() => cycleAgent()}
|
||||||
|
onLongPress={() => cycleAgent(-1)}
|
||||||
|
>
|
||||||
|
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
|
||||||
|
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
|
||||||
|
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.modelChip, isDark && s.modelChipDark]}
|
||||||
|
onPress={() => modelSheetRef.current?.expand()}
|
||||||
|
>
|
||||||
|
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
|
||||||
|
{modelLabel}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Attachment preview */}
|
||||||
|
<ImageAttachments attachments={attachments} isDark={isDark} onRemove={removeAttachment} />
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<View
|
||||||
|
style={[s.inputContainer, isDark && s.inputContainerDark, { paddingBottom: Math.max(12, insets.bottom) }]}
|
||||||
|
>
|
||||||
|
<View style={s.inputRow}>
|
||||||
|
{/* Attach button */}
|
||||||
|
<TouchableOpacity style={s.attachBtn} onPress={pickFromLibrary} onLongPress={pickFromCamera}>
|
||||||
|
<Ionicons name="add-circle-outline" size={26} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Clipboard paste button */}
|
||||||
|
<TouchableOpacity style={s.attachBtn} onPress={pasteFromClipboard}>
|
||||||
|
<Ionicons name="clipboard-outline" size={22} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
||||||
|
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
|
||||||
|
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
||||||
|
value={speech.listening ? speech.transcript : input}
|
||||||
|
onChangeText={speech.listening ? undefined : setInput}
|
||||||
|
editable={!speech.listening}
|
||||||
|
multiline
|
||||||
|
maxLength={10000}
|
||||||
|
/>
|
||||||
|
{/* Stop button: only when busy and no input */}
|
||||||
|
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
||||||
|
<TouchableOpacity style={s.stopBtn} onPress={abortSession}>
|
||||||
|
<Ionicons name="stop" size={20} color="#ffffff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{/* Mic button: when no input, not sending, and not listening */}
|
||||||
|
{!isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
||||||
|
<TouchableOpacity style={s.micBtn} onPress={speech.start}>
|
||||||
|
<Ionicons name="mic" size={22} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{/* Listening indicator: tap to stop */}
|
||||||
|
{speech.listening && (
|
||||||
|
<TouchableOpacity style={s.micBtnActive} onPress={speech.stop}>
|
||||||
|
<Ionicons name="mic" size={22} color="#ffffff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{/* Send button: when there's input */}
|
||||||
|
{!speech.listening && (input.trim() || attachments.length > 0) && (
|
||||||
|
<TouchableOpacity style={s.sendBtn} onPress={handleSend}>
|
||||||
|
<Ionicons name="send" size={20} color="#ffffff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Model picker bottom sheet */}
|
||||||
|
<ModelPicker
|
||||||
|
sheetRef={modelSheetRef}
|
||||||
|
providers={providers}
|
||||||
|
selected={model}
|
||||||
|
isDark={isDark}
|
||||||
|
onSelect={handleModelSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
1
assets/adaptive-icon.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"placeholder": true}
|
||||||
1
assets/favicon.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"placeholder": true}
|
||||||
1
assets/icon.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"placeholder": true}
|
||||||
1
assets/splash-icon.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"placeholder": true}
|
||||||
50
package.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/components/AuthGate.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Ionicons name={iconName} size={64} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
|
<Text style={[styles.title, isDark && styles.textDark]}>OpenCode Locked</Text>
|
||||||
|
<Text style={[styles.subtitle, isDark && styles.subtitleDark]}>Authenticate to access your sessions</Text>
|
||||||
|
|
||||||
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
<TouchableOpacity style={[styles.button, isDark && styles.buttonDark]} onPress={authenticate}>
|
||||||
|
<Ionicons name={iconName} size={24} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
<Text style={[styles.buttonText, isDark && styles.buttonTextDark]}>Unlock</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
147
src/components/chat/DiffView.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.container, isDark && s.containerDark]}>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
style={[
|
||||||
|
s.line,
|
||||||
|
line.type === "add" && (isDark ? s.addDark : s.add),
|
||||||
|
line.type === "remove" && (isDark ? s.removeDark : s.remove),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[s.prefix, isDark && s.prefixDark]}>
|
||||||
|
{line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
s.text,
|
||||||
|
isDark && s.textDark,
|
||||||
|
line.type === "add" && s.addText,
|
||||||
|
line.type === "remove" && s.removeText,
|
||||||
|
]}
|
||||||
|
selectable
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
246
src/components/chat/DirectorySwitcher.tsx
Normal file
@@ -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<BottomSheet | null>
|
||||||
|
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 (
|
||||||
|
<BottomSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
index={-1}
|
||||||
|
snapPoints={["45%", "70%"]}
|
||||||
|
enablePanDownToClose
|
||||||
|
keyboardBehavior="interactive"
|
||||||
|
keyboardBlurBehavior="restore"
|
||||||
|
android_keyboardInputMode="adjustResize"
|
||||||
|
backgroundStyle={isDark ? s.sheetDark : s.sheet}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
|
||||||
|
backdropComponent={(props) => (
|
||||||
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||||
|
)}
|
||||||
|
onChange={(idx) => {
|
||||||
|
if (idx === -1) setCustom("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Text style={[s.title, isDark && s.white]}>Switch Project</Text>
|
||||||
|
{shortCurrent && (
|
||||||
|
<View style={s.current}>
|
||||||
|
<Ionicons name="folder" size={14} color="#8b5cf6" />
|
||||||
|
<Text style={s.currentText} numberOfLines={1}>
|
||||||
|
{shortCurrent}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Custom directory input */}
|
||||||
|
<View style={s.inputWrap}>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
style={[s.input, isDark && s.inputDark]}
|
||||||
|
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={custom}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
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() && (
|
||||||
|
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={handleCustomSubmit}>
|
||||||
|
<Ionicons name="arrow-forward" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick path chips */}
|
||||||
|
{serverHome && (
|
||||||
|
<View style={s.chips}>
|
||||||
|
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
|
||||||
|
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
|
||||||
|
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent directories */}
|
||||||
|
<BottomSheetFlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item: (typeof items)[number], i: number) => item.dir || `default-${i}`}
|
||||||
|
renderItem={({ item }: { item: (typeof items)[number] }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.row, isDark && s.rowDark, item.active && s.rowActive]}
|
||||||
|
onPress={() => handleSelect(item.dir)}
|
||||||
|
>
|
||||||
|
<View style={s.rowIcon}>
|
||||||
|
<Ionicons
|
||||||
|
name={item.dir ? "folder-outline" : "server-outline"}
|
||||||
|
size={20}
|
||||||
|
color={item.active ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={s.rowContent}>
|
||||||
|
<Text style={[s.rowLabel, isDark && s.white, item.active && s.rowLabelActive]} numberOfLines={1}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.dir && (
|
||||||
|
<Text style={[s.rowPath, isDark && s.dimDark]} numberOfLines={1}>
|
||||||
|
{item.dir}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!item.dir && <Text style={[s.rowPath, isDark && s.dimDark]}>Uses server's working directory</Text>}
|
||||||
|
</View>
|
||||||
|
{item.active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={s.list}
|
||||||
|
ListHeaderComponent={
|
||||||
|
items.length > 1 ? <Text style={[s.section, isDark && s.dimDark]}>Recent Projects</Text> : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</BottomSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
82
src/components/chat/ImageAttachments.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.container, isDark && s.containerDark]}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.scroll}>
|
||||||
|
{attachments.map((att, idx) => (
|
||||||
|
<View key={`${att.uri}-${idx}`} style={s.thumb}>
|
||||||
|
<Image source={{ uri: att.uri }} style={s.image} resizeMode="cover" />
|
||||||
|
<TouchableOpacity style={[s.remove, isDark && s.removeDark]} onPress={() => onRemove(idx)}>
|
||||||
|
<Ionicons name="close" size={14} color="#ffffff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{att.filename && (
|
||||||
|
<Text style={[s.label, isDark && s.labelDark]} numberOfLines={1}>
|
||||||
|
{att.filename}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
162
src/components/chat/MessageBubble.tsx
Normal file
@@ -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 (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
s.bubble,
|
||||||
|
isUser ? s.user : s.assistant,
|
||||||
|
isUser && isDark && s.userDark,
|
||||||
|
!isUser && isDark && s.assistantDark,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Role indicator */}
|
||||||
|
<View style={s.header}>
|
||||||
|
<Ionicons
|
||||||
|
name={isUser ? "person" : "sparkles"}
|
||||||
|
size={14}
|
||||||
|
color={isUser ? (isDark ? "#ffffff" : "#0a0a0a") : "#8b5cf6"}
|
||||||
|
/>
|
||||||
|
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{isUser ? "You" : "Assistant"}</Text>
|
||||||
|
{message.model && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.model.modelID}</Text>}
|
||||||
|
{!isUser && message.modelID && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.modelID}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Image attachments */}
|
||||||
|
{fileParts.length > 0 && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={s.imageRow}
|
||||||
|
style={s.imageScroll}
|
||||||
|
>
|
||||||
|
{fileParts.map((fp) => (
|
||||||
|
<View key={fp.id} style={s.imageWrap}>
|
||||||
|
<Image source={{ uri: fp.url }} style={s.attachedImage} resizeMode="cover" />
|
||||||
|
{fp.filename && (
|
||||||
|
<Text style={[s.imageLabel, isDark && s.imageLabelDark]} numberOfLines={1}>
|
||||||
|
{fp.filename}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasoning (collapsible) */}
|
||||||
|
{reasoning.length > 0 && <ReasoningBlock text={reasoning} isDark={isDark} />}
|
||||||
|
|
||||||
|
{/* Message text */}
|
||||||
|
{text.length > 0 &&
|
||||||
|
(isUser ? (
|
||||||
|
<Text style={[s.messageText, isDark && s.textWhite]} selectable>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View style={s.markdownWrap}>
|
||||||
|
<Markdown>{text}</Markdown>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tool calls */}
|
||||||
|
{toolParts.map((tool) => (
|
||||||
|
<ToolCallCard key={tool.id} tool={tool} isDark={isDark} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tokens/cost for assistant messages */}
|
||||||
|
{!isUser && message.tokens && (
|
||||||
|
<Text style={[s.tokens, isDark && s.tokensDark]}>
|
||||||
|
{message.tokens.input + message.tokens.output} tokens
|
||||||
|
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(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" },
|
||||||
|
})
|
||||||
186
src/components/chat/ModelPicker.tsx
Normal file
@@ -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<BottomSheet | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<BottomSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
index={-1}
|
||||||
|
snapPoints={["50%", "80%"]}
|
||||||
|
enablePanDownToClose
|
||||||
|
keyboardBehavior="interactive"
|
||||||
|
keyboardBlurBehavior="restore"
|
||||||
|
android_keyboardInputMode="adjustResize"
|
||||||
|
backgroundStyle={isDark ? s.sheetDark : s.sheet}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
|
||||||
|
backdropComponent={(props) => (
|
||||||
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||||
|
)}
|
||||||
|
onChange={(idx) => {
|
||||||
|
if (idx === -1) setSearch("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Text style={[s.title, isDark && s.textWhite]}>Select Model</Text>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
style={[s.search, isDark && s.searchDark]}
|
||||||
|
placeholder="Search models..."
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<BottomSheetSectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item: ModelItem) => `${item.providerID}/${item.modelID}`}
|
||||||
|
renderSectionHeader={({ section }: { section: { title: string } }) => (
|
||||||
|
<View style={[s.sectionHeader, isDark && s.sectionHeaderDark]}>
|
||||||
|
<Text style={[s.sectionTitle, isDark && s.metaDark]}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }: { item: ModelItem }) => {
|
||||||
|
const active = selected?.providerID === item.providerID && selected?.modelID === item.modelID
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
|
||||||
|
onPress={() => handleSelect(item.providerID, item.modelID)}
|
||||||
|
>
|
||||||
|
<View style={s.rowText}>
|
||||||
|
<Text style={[s.rowName, isDark && s.textWhite]} numberOfLines={1}>
|
||||||
|
{item.modelName || item.modelID}
|
||||||
|
</Text>
|
||||||
|
<Text style={[s.rowProvider, isDark && s.metaDark]}>{item.providerName || item.providerID}</Text>
|
||||||
|
</View>
|
||||||
|
{active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
contentContainerStyle={s.content}
|
||||||
|
stickySectionHeadersEnabled
|
||||||
|
/>
|
||||||
|
</BottomSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
61
src/components/chat/PermissionPrompt.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.card, isDark && s.cardDark]}>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Ionicons name="shield-outline" size={18} color="#f59e0b" />
|
||||||
|
<Text style={[s.title, isDark && s.textWhite]}>Permission Required</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.type, isDark && s.typeDark]}>
|
||||||
|
{permission.permission}: {permission.patterns.join(", ")}
|
||||||
|
</Text>
|
||||||
|
<View style={s.actions}>
|
||||||
|
<TouchableOpacity style={[s.btn, s.deny]} onPress={() => onReply("reject")}>
|
||||||
|
<Text style={s.denyText}>Deny</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.btn, s.always, isDark && s.alwaysDark]} onPress={() => onReply("always")}>
|
||||||
|
<Text style={[s.alwaysText, isDark && s.textWhite]}>Always</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[s.btn, s.allow, isDark && s.allowDark]} onPress={() => onReply("once")}>
|
||||||
|
<Text style={[s.allowText, isDark && s.allowTextDark]}>Allow</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
190
src/components/chat/QuestionPrompt.tsx
Normal file
@@ -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<string[][]>(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 (
|
||||||
|
<View style={[s.card, isDark && s.cardDark]}>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
|
||||||
|
<Text style={[s.title, isDark && s.textWhite]}>{q.header || "Question"}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
|
||||||
|
|
||||||
|
<View style={s.options}>
|
||||||
|
{q.options.map((opt) => {
|
||||||
|
const selected = (answers[current] || []).includes(opt.label)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={opt.label}
|
||||||
|
style={[
|
||||||
|
s.option,
|
||||||
|
isDark && s.optionDark,
|
||||||
|
selected && s.optionSelected,
|
||||||
|
selected && isDark && s.optionSelectedDark,
|
||||||
|
]}
|
||||||
|
onPress={() => toggleOption(opt.label)}
|
||||||
|
>
|
||||||
|
<Text style={[s.optionLabel, isDark && s.textWhite, selected && s.optionLabelSelected]}>{opt.label}</Text>
|
||||||
|
{opt.description ? <Text style={[s.optionDesc, isDark && s.metaDark]}>{opt.description}</Text> : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{q.custom !== false &&
|
||||||
|
(showCustom ? (
|
||||||
|
<View style={s.customRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[s.customInput, isDark && s.customInputDark]}
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||||
|
value={custom}
|
||||||
|
onChangeText={setCustom}
|
||||||
|
onSubmitEditing={submitCustom}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={submitCustom} style={s.customSubmit}>
|
||||||
|
<Ionicons name="send" size={18} color="#8b5cf6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
|
||||||
|
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>Type your own answer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={s.footer}>
|
||||||
|
<TouchableOpacity onPress={onReject}>
|
||||||
|
<Text style={[s.dismiss, isDark && s.metaDark]}>Dismiss</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{(request.questions.length > 1 || q.multiple) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.submitBtn, isDark && s.submitBtnDark]}
|
||||||
|
onPress={() => {
|
||||||
|
if (current < request.questions.length - 1) {
|
||||||
|
setCurrent(current + 1)
|
||||||
|
} else {
|
||||||
|
onReply(answers)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={s.submitText}>{current < request.questions.length - 1 ? "Next" : "Submit"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
48
src/components/chat/ReasoningBlock.tsx
Normal file
@@ -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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.block, isDark && s.blockDark]}
|
||||||
|
onPress={() => setExpanded(!expanded)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={s.header}>
|
||||||
|
<Ionicons name="bulb-outline" size={14} color="#f59e0b" />
|
||||||
|
<Text style={[s.label, isDark && s.labelDark]}>Thinking</Text>
|
||||||
|
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</View>
|
||||||
|
{expanded && (
|
||||||
|
<Text style={[s.text, isDark && s.textDark]} selectable>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
338
src/components/chat/SessionInfo.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.container, isDark && s.containerDark]}>
|
||||||
|
{/* Top row: tokens + context % + cost — matches TUI header */}
|
||||||
|
<View style={s.row}>
|
||||||
|
<View style={s.costRow}>
|
||||||
|
<Ionicons name="stats-chart-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
{hasTokens && (
|
||||||
|
<Text style={[s.tokens, isDark && s.textDark]}>
|
||||||
|
{stats.total.toLocaleString()}
|
||||||
|
{stats.percent > 0 && <Text style={[s.percent, isDark && s.dimDark]}>{` ${stats.percent}%`}</Text>}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasCost && <Text style={[s.cost, isDark && s.dimDark]}>({formatCost(stats.cost)})</Text>}
|
||||||
|
{!hasTokens && !hasCost && <Text style={[s.cost, isDark && s.dimDark]}>No usage data yet</Text>}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={onClose} hitSlop={8}>
|
||||||
|
<Ionicons name="close" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Context bar */}
|
||||||
|
{stats.percent > 0 && (
|
||||||
|
<View style={[s.bar, isDark && s.barDark]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
s.barFill,
|
||||||
|
{ width: `${Math.min(stats.percent, 100)}%` },
|
||||||
|
stats.percent > 80 ? s.barWarn : stats.percent > 50 ? s.barMid : s.barOk,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token breakdown pills */}
|
||||||
|
{hasTokens && (
|
||||||
|
<View style={s.breakdown}>
|
||||||
|
<TokenPill label="In" value={stats.input} color="#3b82f6" isDark={isDark} />
|
||||||
|
<TokenPill label="Out" value={stats.output} color="#10b981" isDark={isDark} />
|
||||||
|
{stats.reasoning > 0 && <TokenPill label="Think" value={stats.reasoning} color="#f59e0b" isDark={isDark} />}
|
||||||
|
{stats.cacheRead > 0 && <TokenPill label="Cache R" value={stats.cacheRead} color="#8b5cf6" isDark={isDark} />}
|
||||||
|
{stats.cacheWrite > 0 && (
|
||||||
|
<TokenPill label="Cache W" value={stats.cacheWrite} color="#ec4899" isDark={isDark} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session metadata */}
|
||||||
|
<View style={s.meta}>
|
||||||
|
{created && <MetaItem icon="time-outline" label="Created" value={formatTime(created)} isDark={isDark} />}
|
||||||
|
{updated && updated !== created && (
|
||||||
|
<MetaItem icon="refresh-outline" label="Updated" value={formatTime(updated)} isDark={isDark} />
|
||||||
|
)}
|
||||||
|
<MetaItem
|
||||||
|
icon="chatbubbles-outline"
|
||||||
|
label="Messages"
|
||||||
|
value={String(messages.length) + (hasMore ? "+" : "")}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
{summary && summary.files > 0 && (
|
||||||
|
<MetaItem
|
||||||
|
icon="code-outline"
|
||||||
|
label="Changes"
|
||||||
|
value={`${summary.files}f +${summary.additions} -${summary.deletions}`}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{session?.share?.url && <MetaItem icon="share-outline" label="Shared" value="Yes" isDark={isDark} />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Navigation actions */}
|
||||||
|
<View style={s.actions}>
|
||||||
|
{hasMore && (
|
||||||
|
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onLoadAll} disabled={loadingAll}>
|
||||||
|
{loadingAll ? (
|
||||||
|
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="download-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
)}
|
||||||
|
<Text style={[s.actionText, isDark && s.dimDark]}>{loadingAll ? "Loading..." : "Load all messages"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onScrollToTop}>
|
||||||
|
<Ionicons name="arrow-up-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
|
<Text style={[s.actionText, isDark && s.dimDark]}>Jump to beginning</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaItem({ icon, label, value, isDark }: { icon: string; label: string; value: string; isDark: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={s.metaItem}>
|
||||||
|
<Ionicons name={icon as any} size={12} color={isDark ? "#555555" : "#999999"} />
|
||||||
|
<Text style={[s.metaLabel, isDark && s.dimDark]}>{label}</Text>
|
||||||
|
<Text style={[s.metaValue, isDark && s.metaValueDark]}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TokenPill({ label, value, color, isDark }: { label: string; value: number; color: string; isDark: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={[s.pill, { borderColor: color + "40" }, isDark && { backgroundColor: color + "15" }]}>
|
||||||
|
<View style={[s.dot, { backgroundColor: color }]} />
|
||||||
|
<Text style={[s.pillLabel, isDark && s.dimDark]}>{label}</Text>
|
||||||
|
<Text style={[s.pillValue, isDark && s.textDark]}>{compact(value)}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
88
src/components/chat/SlashPopover.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.popover, isDark && s.popoverDark]}>
|
||||||
|
<ScrollView keyboardShouldPersistTaps="always" style={s.scroll}>
|
||||||
|
{filtered.map((cmd) => (
|
||||||
|
<TouchableOpacity key={cmd.trigger} style={[s.item, isDark && s.itemDark]} onPress={() => onSelect(cmd)}>
|
||||||
|
<Ionicons
|
||||||
|
name={cmd.icon as any}
|
||||||
|
size={18}
|
||||||
|
color={cmd.type === "custom" ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
|
||||||
|
/>
|
||||||
|
<View style={s.textCol}>
|
||||||
|
<Text style={[s.trigger, isDark && s.textWhite]}>/{cmd.trigger}</Text>
|
||||||
|
{cmd.description && (
|
||||||
|
<Text style={[s.desc, isDark && s.metaDark]} numberOfLines={1}>
|
||||||
|
{cmd.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{cmd.type === "custom" && (
|
||||||
|
<View style={[s.badge, isDark && s.badgeDark]}>
|
||||||
|
<Text style={s.badgeText}>cmd</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
46
src/components/chat/StatusIndicator.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[s.bar, isDark && s.barDark]}>
|
||||||
|
<ActivityIndicator size="small" color="#8b5cf6" />
|
||||||
|
<Text style={[s.text, isDark && s.textDark]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
454
src/components/chat/ToolCallCard.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, unknown>).command : undefined
|
||||||
|
const out = typeof output === "string" ? output : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof cmd === "string" && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable>
|
||||||
|
<Text style={s.codePrompt}>$ </Text>
|
||||||
|
{cmd}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{out !== undefined && out.length > 0 && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={80}>
|
||||||
|
{out}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
|
||||||
|
const offset = typeof input === "object" && input !== null ? (input as Record<string, unknown>).offset : undefined
|
||||||
|
const limit = typeof input === "object" && input !== null ? (input as Record<string, unknown>).limit : undefined
|
||||||
|
const range = offset || limit ? ` (${offset || 0}..${limit || "end"})` : ""
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof file === "string" && (
|
||||||
|
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
|
||||||
|
{file}
|
||||||
|
{range}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
|
||||||
|
const content = typeof input === "object" && input !== null ? (input as Record<string, unknown>).content : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof file === "string" && (
|
||||||
|
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
|
||||||
|
{file}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{typeof content === "string" && content.length > 0 && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
|
||||||
|
{content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
|
||||||
|
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
|
||||||
|
const old = typeof input === "object" && input !== null ? (input as Record<string, unknown>).oldString : undefined
|
||||||
|
const replacement =
|
||||||
|
typeof input === "object" && input !== null ? (input as Record<string, unknown>).newString : undefined
|
||||||
|
|
||||||
|
// If we have old/new strings, show as diff
|
||||||
|
if (typeof old === "string" && typeof replacement === "string") {
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof file === "string" && (
|
||||||
|
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
|
||||||
|
{file}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<DiffView before={old} after={replacement} isDark={isDark} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: show raw output
|
||||||
|
const text = typeof output === "string" ? output : JSON.stringify(output, null, 2)
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof file === "string" && (
|
||||||
|
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
|
||||||
|
{file}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const patch = typeof input === "object" && input !== null ? (input as Record<string, unknown>).patch : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof patch === "string" && patch.length > 0 && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={60}>
|
||||||
|
{patch}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
|
||||||
|
const pattern = typeof input === "object" && input !== null ? (input as Record<string, unknown>).pattern : undefined
|
||||||
|
const path = typeof input === "object" && input !== null ? (input as Record<string, unknown>).path : undefined
|
||||||
|
const results = typeof output === "string" ? output : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof pattern === "string" && (
|
||||||
|
<Text style={[s.detailMeta, isDark && s.detailMetaDark]}>
|
||||||
|
Pattern: {pattern}
|
||||||
|
{typeof path === "string" ? ` in ${path}` : ""}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
|
||||||
|
{results}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebfetchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const url = typeof input === "object" && input !== null ? (input as Record<string, unknown>).url : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof url === "string" && (
|
||||||
|
<Text style={[s.detailFile, isDark && s.detailFileDark, { color: "#8b5cf6" }]} selectable numberOfLines={3}>
|
||||||
|
{url}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const description =
|
||||||
|
typeof input === "object" && input !== null ? (input as Record<string, unknown>).description : undefined
|
||||||
|
const prompt = typeof input === "object" && input !== null ? (input as Record<string, unknown>).prompt : undefined
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{typeof description === "string" && <Text style={[s.detailMeta, isDark && s.detailMetaDark]}>{description}</Text>}
|
||||||
|
{typeof prompt === "string" && prompt.length > 0 && (
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={20}>
|
||||||
|
{prompt}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TodoDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
|
||||||
|
const todos = typeof input === "object" && input !== null ? (input as Record<string, unknown>).todos : undefined
|
||||||
|
if (!Array.isArray(todos)) return null
|
||||||
|
return (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
{todos.map((t, i) => {
|
||||||
|
const item = t as Record<string, unknown>
|
||||||
|
const done = item.status === "completed"
|
||||||
|
return (
|
||||||
|
<View key={String(item.id || i)} style={s.todoRow}>
|
||||||
|
<Ionicons
|
||||||
|
name={done ? "checkbox" : "square-outline"}
|
||||||
|
size={16}
|
||||||
|
color={done ? "#22c55e" : isDark ? "#666666" : "#999999"}
|
||||||
|
/>
|
||||||
|
<Text style={[s.todoText, isDark && s.todoTextDark, done && s.todoDone]} numberOfLines={2}>
|
||||||
|
{String(item.content || item.title || "")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={s.detailSection}>
|
||||||
|
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
|
||||||
|
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <BashDetail input={input} output={output} isDark={isDark} />
|
||||||
|
case "read":
|
||||||
|
return <ReadDetail input={input} isDark={isDark} />
|
||||||
|
case "write":
|
||||||
|
return <WriteDetail input={input} isDark={isDark} />
|
||||||
|
case "edit":
|
||||||
|
return <EditDetail input={input} output={output} isDark={isDark} />
|
||||||
|
case "apply_patch":
|
||||||
|
return <PatchDetail input={input} isDark={isDark} />
|
||||||
|
case "glob":
|
||||||
|
case "grep":
|
||||||
|
case "list":
|
||||||
|
case "codesearch":
|
||||||
|
return <GlobGrepDetail input={input} output={output} isDark={isDark} />
|
||||||
|
case "webfetch":
|
||||||
|
case "websearch":
|
||||||
|
return <WebfetchDetail input={input} isDark={isDark} />
|
||||||
|
case "task":
|
||||||
|
return <TaskDetail input={input} isDark={isDark} />
|
||||||
|
case "todowrite":
|
||||||
|
return <TodoDetail input={input} isDark={isDark} />
|
||||||
|
default:
|
||||||
|
return <GenericDetail input={input} output={output} isDark={isDark} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Error display ---
|
||||||
|
function ErrorBanner({ message, isDark }: { message: string; isDark: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={[s.errorBanner, isDark && s.errorBannerDark]}>
|
||||||
|
<Ionicons name="alert-circle" size={14} color="#ef4444" />
|
||||||
|
<Text style={s.errorText} numberOfLines={3} selectable>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
s.card,
|
||||||
|
isDark && s.cardDark,
|
||||||
|
status === "error" && s.cardError,
|
||||||
|
status === "error" && isDark && s.cardErrorDark,
|
||||||
|
]}
|
||||||
|
onPress={toggle}
|
||||||
|
activeOpacity={hasDetail ? 0.7 : 1}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<View style={s.header}>
|
||||||
|
<View style={s.headerLeft}>
|
||||||
|
<Ionicons name={icon as any} size={16} color={color} />
|
||||||
|
<Text style={[s.name, isDark && s.nameDark]} numberOfLines={1}>
|
||||||
|
{tool.state?.title || tool.tool || "Tool"}
|
||||||
|
</Text>
|
||||||
|
{elapsed && <Text style={[s.elapsed, isDark && s.elapsedDark]}>{elapsed}</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={s.headerRight}>
|
||||||
|
{status === "running" && <ActivityIndicator size="small" color={color} />}
|
||||||
|
{status === "completed" && <Ionicons name="checkmark-circle" size={16} color="#22c55e" />}
|
||||||
|
{status === "error" && <Ionicons name="close-circle" size={16} color="#ef4444" />}
|
||||||
|
{hasDetail && (
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={16}
|
||||||
|
color={isDark ? "#666666" : "#999999"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{error && !expanded && <ErrorBanner message={error} isDark={isDark} />}
|
||||||
|
|
||||||
|
{/* Expanded detail */}
|
||||||
|
{expanded && (
|
||||||
|
<ScrollView style={s.detailScroll} nestedScrollEnabled showsVerticalScrollIndicator={false}>
|
||||||
|
{error && <ErrorBanner message={error} isDark={isDark} />}
|
||||||
|
<ToolDetail tool={tool} isDark={isDark} />
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
})
|
||||||
12
src/components/chat/index.ts
Normal file
@@ -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"
|
||||||
85
src/components/markdown/CodeBlock.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={[styles.container, isDark && styles.containerDark]}>
|
||||||
|
<View style={[styles.header, isDark && styles.headerDark]}>
|
||||||
|
<Text style={[styles.language, isDark && styles.languageDark]}>{language || "code"}</Text>
|
||||||
|
<TouchableOpacity onPress={copy} hitSlop={8}>
|
||||||
|
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.code, isDark && styles.codeDark]} selectable>
|
||||||
|
{code}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
117
src/components/markdown/Markdown.tsx
Normal file
@@ -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 (
|
||||||
|
<View key={this.getKey()} style={containerStyle}>
|
||||||
|
<CodeBlock code={text} language={language} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
codespan(text: string, styles?: TextStyle): ReactNode {
|
||||||
|
return (
|
||||||
|
<Text selectable key={this.getKey()} style={[styles, { fontStyle: "normal", fontWeight: "normal" }]}>
|
||||||
|
{text}
|
||||||
|
</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 (
|
||||||
|
<RNMarkdown
|
||||||
|
value={children}
|
||||||
|
renderer={renderer}
|
||||||
|
styles={theme}
|
||||||
|
flatListProps={{
|
||||||
|
scrollEnabled: false,
|
||||||
|
initialNumToRender: 50,
|
||||||
|
style: { backgroundColor: "transparent" },
|
||||||
|
contentContainerStyle: { backgroundColor: "transparent" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/components/markdown/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Markdown } from "./Markdown"
|
||||||
|
export { CodeBlock } from "./CodeBlock"
|
||||||
174
src/lib/notifications.ts
Normal file
@@ -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<Category, { label: string; description: string; icon: string }> = {
|
||||||
|
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<Category, boolean> = {
|
||||||
|
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<Category, boolean>
|
||||||
|
|
||||||
|
let preferences: PreferencesAccessor = () => defaultPreferences
|
||||||
|
const dedupeSentAt = new Map<string, number>()
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string, unknown>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
434
src/lib/sdk.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<Response> {
|
||||||
|
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<HealthResponse>(config, "/global/health"),
|
||||||
|
// SSE event stream - returns async iterator
|
||||||
|
// Pass an AbortSignal to cancel the connection
|
||||||
|
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
|
||||||
|
const url = `${config.baseUrl}/global/event`
|
||||||
|
const headers = createHeaders(config)
|
||||||
|
// Remove Content-Type for SSE (it's text/event-stream)
|
||||||
|
delete (headers as Record<string, string>)["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<Project[]>(config, "/project"),
|
||||||
|
current: () => request<Project>(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<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),
|
||||||
|
|
||||||
|
create: (params?: { title?: string }) =>
|
||||||
|
request<Session>(config, "/session", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(params || {}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (sessionID: string) => request<void>(config, `/session/${sessionID}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
|
||||||
|
request<Session>(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<MessageWithParts[]>(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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<boolean>(config, `/session/${sessionID}/abort`, { method: "POST" }),
|
||||||
|
|
||||||
|
diff: (sessionID: string, messageID?: string) => {
|
||||||
|
const qs = messageID ? `?messageID=${messageID}` : ""
|
||||||
|
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
permission: {
|
||||||
|
list: () =>
|
||||||
|
request<Array<{ id: string; sessionID: string; tool: string; input: unknown }>>(config, "/permission"),
|
||||||
|
|
||||||
|
reply: (requestID: string, reply: "once" | "always" | "reject") =>
|
||||||
|
request<boolean>(config, `/permission/${requestID}/reply`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reply }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
question: {
|
||||||
|
list: () => request<Array<{ id: string; sessionID: string; questions: unknown[] }>>(config, "/question"),
|
||||||
|
|
||||||
|
reply: (requestID: string, answers: string[][]) =>
|
||||||
|
request<boolean>(config, `/question/${requestID}/reply`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ answers }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
reject: (requestID: string) =>
|
||||||
|
request<boolean>(config, `/question/${requestID}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
agent: {
|
||||||
|
list: () => request<Agent[]>(config, "/agent"),
|
||||||
|
},
|
||||||
|
|
||||||
|
command: {
|
||||||
|
list: () => request<Command[]>(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<string, string>
|
||||||
|
connected: string[]
|
||||||
|
}>(config, "/provider"),
|
||||||
|
},
|
||||||
|
|
||||||
|
config: {
|
||||||
|
get: () => request<unknown>(config, "/config"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Client = ReturnType<typeof createClient>
|
||||||
80
src/lib/speech.ts
Normal file
@@ -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<void>
|
||||||
|
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<string | null>(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 }
|
||||||
|
}
|
||||||
32
src/lib/types.ts
Normal file
@@ -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"
|
||||||
135
src/stores/auth.ts
Normal file
@@ -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<void>
|
||||||
|
authenticate: () => Promise<boolean>
|
||||||
|
authenticateForMessage: () => Promise<boolean>
|
||||||
|
updateSettings: (settings: Partial<AuthSettings>) => Promise<void>
|
||||||
|
lock: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AuthSettings = {
|
||||||
|
requireBiometric: false,
|
||||||
|
requireBiometricForMessages: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthState>((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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
139
src/stores/catalog.ts
Normal file
@@ -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<string, string>
|
||||||
|
// Current selections
|
||||||
|
agent: string // agent name, e.g. "build"
|
||||||
|
model: ModelSelection | null
|
||||||
|
loaded: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
load: () => Promise<void>
|
||||||
|
setAgent: (name: string) => void
|
||||||
|
setModel: (selection: ModelSelection | null) => void
|
||||||
|
cycleAgent: (direction?: 1 | -1) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCatalog = create<CatalogState>((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)
|
||||||
|
},
|
||||||
|
}))
|
||||||
312
src/stores/connections.ts
Normal file
@@ -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<void>
|
||||||
|
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
|
||||||
|
removeConnection: (id: string) => Promise<void>
|
||||||
|
setActiveConnection: (id: string) => Promise<void>
|
||||||
|
testConnection: (connection: ServerConnection, password?: string) => Promise<boolean>
|
||||||
|
updateConnection: (id: string, updates: Partial<ServerConnection>) => Promise<void>
|
||||||
|
refreshProject: () => Promise<void>
|
||||||
|
// 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<void>
|
||||||
|
// Record a directory as recently used
|
||||||
|
addRecentDirectory: (directory: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ConnectionsState>((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))
|
||||||
|
},
|
||||||
|
}))
|
||||||
371
src/stores/events.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, SessionStatus>
|
||||||
|
statusText: Record<string, string>
|
||||||
|
// Permissions & questions (pending per session)
|
||||||
|
permissions: Record<
|
||||||
|
string,
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
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<typeof setTimeout> | 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<string, unknown>) => p.sessionID === sessionID)
|
||||||
|
const sessionQuestions = (questions || []).filter((q: Record<string, unknown>) => 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<EventsState>((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: {},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
374
src/stores/sessions.ts
Normal file
@@ -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<string, Part[]> } {
|
||||||
|
const messages: Message[] = []
|
||||||
|
const parts: Record<string, Part[]> = {}
|
||||||
|
|
||||||
|
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<string, Part[]>
|
||||||
|
isLoading: boolean
|
||||||
|
// Per-session optimistic sending flag — bridging gap between user tap and SSE busy
|
||||||
|
sending: Record<string, boolean>
|
||||||
|
loadingMore: boolean
|
||||||
|
hasMore: boolean
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadSessions: () => Promise<void>
|
||||||
|
selectSession: (sessionID: string, directory?: string) => Promise<void>
|
||||||
|
loadOlderMessages: () => Promise<void>
|
||||||
|
createSession: (title?: string) => Promise<Session | null>
|
||||||
|
deleteSession: (sessionID: string) => Promise<void>
|
||||||
|
sendMessage: (
|
||||||
|
text: string,
|
||||||
|
model?: { providerID: string; modelID: string },
|
||||||
|
agent?: string,
|
||||||
|
files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>,
|
||||||
|
) => Promise<void>
|
||||||
|
abortSession: () => Promise<void>
|
||||||
|
refreshMessages: () => Promise<void>
|
||||||
|
|
||||||
|
// 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<SessionsState>((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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
59
src/stores/settings.ts
Normal file
@@ -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<Category, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: Settings = {
|
||||||
|
pageSize: 25,
|
||||||
|
notifications: { ...defaultPreferences },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsState extends Settings {
|
||||||
|
loaded: boolean
|
||||||
|
load: () => Promise<void>
|
||||||
|
setPageSize: (size: number) => Promise<void>
|
||||||
|
setNotification: (category: Category, enabled: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SettingsState>((set, get) => ({
|
||||||
|
...DEFAULTS,
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
const raw = await SecureStore.getItemAsync(SETTINGS_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Settings>
|
||||||
|
// 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 })
|
||||||
|
},
|
||||||
|
}))
|
||||||
31
src/types/expo-icons.d.ts
vendored
Normal file
@@ -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<IconProps> {
|
||||||
|
glyphMap: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
100
src/types/react-native-marked.d.ts
vendored
Normal file
@@ -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<string, string>
|
||||||
|
spacing?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkedStyles {
|
||||||
|
[key: string]: ViewStyle | TextStyle | ImageStyle | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownProps {
|
||||||
|
value: string
|
||||||
|
flatListProps?: Omit<FlatListProps<ReactNode>, "data" | "renderItem" | "horizontal">
|
||||||
|
theme?: UserTheme
|
||||||
|
renderer?: RendererInterface
|
||||||
|
styles?: MarkedStyles | Record<string, unknown>
|
||||||
|
baseUrl?: string
|
||||||
|
tokenizer?: Tokenizer
|
||||||
|
hooks?: Hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
const Markdown: React.FC<MarkdownProps>
|
||||||
|
export default Markdown
|
||||||
|
}
|
||||||
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||