diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..7dde48b --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +mapfile -t STAGED_DART_FILES < <( + git diff --cached --name-only --diff-filter=ACMR \ + | grep -E '^apps/mobile/.*\.dart$' \ + | grep -Ev '\.(g|freezed)\.dart$' \ + || true +) + +mapfile -t STAGED_BRIDGE_FILES < <( + git diff --cached --name-only --diff-filter=ACMR \ + | grep -E '^packages/bridge/' \ + | grep -Ev '^packages/bridge/(dist|node_modules)/' \ + | grep -E '\.(ts|js|cjs|mjs|json)$' \ + || true +) + +if [[ ${#STAGED_DART_FILES[@]} -eq 0 && ${#STAGED_BRIDGE_FILES[@]} -eq 0 ]]; then + echo "pre-commit: no staged mobile or bridge source files found; skipping checks." + exit 0 +fi + +if [[ ${#STAGED_DART_FILES[@]} -gt 0 ]]; then + echo "pre-commit: formatting staged Dart files" + dart format "${STAGED_DART_FILES[@]}" + git add "${STAGED_DART_FILES[@]}" + + ANALYZE_TARGETS=() + for file in "${STAGED_DART_FILES[@]}"; do + ANALYZE_TARGETS+=("${file#apps/mobile/}") + done + + echo "pre-commit: analyzing staged Dart files" + pushd apps/mobile > /dev/null + flutter analyze "${ANALYZE_TARGETS[@]}" + popd > /dev/null +fi + +if [[ ${#STAGED_BRIDGE_FILES[@]} -gt 0 ]]; then + if [[ ! -d packages/bridge/node_modules ]]; then + echo "pre-commit: packages/bridge/node_modules is missing. Run 'cd packages/bridge && npm ci' first." >&2 + exit 1 + fi + + BRIDGE_RELATIVE_FILES=() + for file in "${STAGED_BRIDGE_FILES[@]}"; do + BRIDGE_RELATIVE_FILES+=("${file#packages/bridge/}") + done + + echo "pre-commit: formatting staged bridge files" + pushd packages/bridge > /dev/null + npm exec prettier -- --write "${BRIDGE_RELATIVE_FILES[@]}" + popd > /dev/null + git add "${STAGED_BRIDGE_FILES[@]}" + + echo "pre-commit: typechecking bridge" + pushd packages/bridge > /dev/null + npm run typecheck + + echo "pre-commit: running bridge tests" + npm run test:ci + + echo "pre-commit: building bridge" + npm run build + popd > /dev/null +fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..93de0db --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,164 @@ +name: Deploy + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + environment: + description: "Deployment environment" + required: true + default: staging + type: choice + options: + - staging + - production + +jobs: + build-android: + name: Build Android + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Decode keystore + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks + + - name: Build App Bundle + env: + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: flutter build appbundle --release + + - name: Upload AAB artifact + uses: actions/upload-artifact@v4 + with: + name: android-release + path: apps/mobile/build/app/outputs/bundle/release/app-release.aab + + build-ios: + name: Build iOS + runs-on: macos-latest + defaults: + run: + working-directory: apps/mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: stable + cache: true + + - name: Install Ruby + Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Fastlane — certificates + working-directory: . + env: + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APP_PASSWORD }} + run: bundle exec fastlane ios certificates + + - name: Fastlane — build + working-directory: . + env: + MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + run: bundle exec fastlane ios build + + - name: Upload IPA artifact + uses: actions/upload-artifact@v4 + with: + name: ios-release + path: apps/mobile/build/ios/iphoneos/Runner.app + + deploy-android: + name: Deploy Android + runs-on: ubuntu-latest + needs: build-android + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/checkout@v4 + + - name: Download AAB + uses: actions/download-artifact@v4 + with: + name: android-release + + - name: Install Ruby + Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Deploy to Play Store + env: + SUPPLY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }} + run: bundle exec fastlane android deploy + + deploy-ios: + name: Deploy iOS + runs-on: macos-latest + needs: build-ios + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/checkout@v4 + + - name: Download IPA + uses: actions/download-artifact@v4 + with: + name: ios-release + + - name: Install Ruby + Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + + - name: Deploy to TestFlight + env: + FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APP_PASSWORD }} + FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} + run: bundle exec fastlane ios testflight diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..5c10c0f --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: Deploy Docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - "docs-site/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Upload docs-site as Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs-site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + update-repo: + name: Update repo metadata + runs-on: ubuntu-latest + needs: deploy + permissions: + contents: read + steps: + - name: Set homepage URL and description + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh repo edit RecursiveDev/ReCursor \ + --homepage "https://recursivedev.github.io/ReCursor" \ + --description "Mobile-first companion UI for AI coding workflows - Flutter app with OpenCode-like UX, Claude Code Hooks integration, and Agent SDK support" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..79c6387 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,131 @@ +name: Test + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +jobs: + flutter-test: + name: Flutter Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Analyze + run: flutter analyze + + - name: Run unit & widget tests + run: flutter test --coverage + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: apps/mobile/coverage/lcov.info + flags: flutter + + bridge-test: + name: Bridge Server Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/bridge + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: packages/bridge/package.json + + - name: Install dependencies + run: npm install + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test + + flutter-build-android: + name: Android Build Check + runs-on: ubuntu-latest + needs: flutter-test + if: github.event_name == 'push' + defaults: + run: + working-directory: apps/mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Build APK (debug) + run: flutter build apk --debug + + flutter-build-ios: + name: iOS Build Check + runs-on: macos-latest + needs: flutter-test + if: github.event_name == 'push' + defaults: + run: + working-directory: apps/mobile + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run code generation + run: dart run build_runner build --delete-conflicting-outputs + + - name: Build iOS (no codesign) + run: flutter build ios --debug --no-codesign diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..dc70d2e --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "fastlane", "~> 2.220" + +plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile") +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/README.md b/README.md index 3602074..6db52b3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- - ReCursor Logo + + ReCursor Logo

@@ -58,9 +58,10 @@ This repository is **work in progress**. - ✅ Repo structure + documentation are being established. -- ⏳ Flutter app and bridge server implementation are not yet shipped. +- ✅ The mobile direction is now bridge-first: pair with your local bridge, no sign-in required. +- ⏳ Flutter app and bridge server implementation are still being completed. -If you're new here, start with: **`docs/README.md`**. +If you're new here, start with: **`docs/README.md`** and the bridge pairing flow in `docs/wireframes/01-startup.md`. --- @@ -75,11 +76,18 @@ If you're new here, start with: **`docs/README.md`**. ### Important constraint (Claude Code) -Claude Code's **Remote Control** feature is **first-party** (designed for `claude.ai/code` and official Claude apps). ReCursor docs are written to avoid claiming access to any private/undocumented Remote Control protocol. +Claude Code's **Remote Control** feature is **first-party only** (designed for `claude.ai/code` and official Claude apps). There is no public API for third-party clients to join or mirror existing Claude Code sessions. -ReCursor's supported approach is: -- **Claude Code Hooks**: event observation (one-way) -- **Claude Agent SDK**: a **parallel, controllable** agent session that ReCursor can drive (approvals/tool execution live here) +ReCursor's supported integration paths: +- **Claude Code Hooks**: HTTP-based event observation (one-way) +- **Claude Agent SDK**: **parallel, controllable** agent sessions that ReCursor can drive + +### Bridge-first, no-login workflow + +ReCursor uses a **bridge-first** connection model: +- The mobile app connects directly to a **user-controlled desktop bridge** (no hosted service, no user accounts) +- On startup, the app restores saved bridge pairings or guides through QR-code pairing +- Remote access is achieved via secure tunnels (Tailscale, WireGuard) to the user's own bridge — not through unsupported third-party Claude Remote Control access --- diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/mobile/.metadata b/apps/mobile/.metadata new file mode 100644 index 0000000..df13aa7 --- /dev/null +++ b/apps/mobile/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + - platform: android + create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..6ac17cf --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,17 @@ +# recursor_mobile + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml index 388cd92..7e160a5 100644 --- a/apps/mobile/analysis_options.yaml +++ b/apps/mobile/analysis_options.yaml @@ -1,3 +1,18 @@ include: package:flutter_lints/flutter.yaml -# Scaffold-only: customize analysis/lints as the app is implemented. +analyzer: + exclude: + - '**/*.g.dart' + - '**/*.freezed.dart' + +linter: + rules: + always_declare_return_types: true + avoid_print: true + directives_ordering: true + prefer_const_constructors: true + prefer_const_declarations: true + prefer_final_fields: true + prefer_final_locals: true + sort_child_properties_last: true + unawaited_futures: true diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..3eee2d7 --- /dev/null +++ b/apps/mobile/android/app/build.gradle.kts @@ -0,0 +1,79 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use(::load) + } +} + +val isWindows = System.getProperty("os.name").contains("Windows", ignoreCase = true) +val flutterSdk = localProperties.getProperty("flutter.sdk") ?: System.getenv("FLUTTER_ROOT") +val flutterExecutable = when { + !flutterSdk.isNullOrBlank() && isWindows -> "$flutterSdk\\bin\\flutter.bat" + !flutterSdk.isNullOrBlank() -> "$flutterSdk/bin/flutter" + isWindows -> "flutter.bat" + else -> "flutter" +} +val flutterProjectDir = rootProject.projectDir.parentFile + +val analyzeFlutterBeforeBuild = tasks.register("analyzeFlutterBeforeBuild") { + group = "verification" + description = "Runs flutter analyze before Android builds." + workingDir = flutterProjectDir + commandLine(flutterExecutable, "analyze", "--no-fatal-infos", "--no-fatal-warnings") +} + +tasks.named("preBuild") { + dependsOn(analyzeFlutterBeforeBuild) +} + +android { + namespace = "com.example.recursor_mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.recursor_mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2612599 --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 539ab02..fc67028 100644 --- a/apps/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,5 +15,60 @@ public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); + } + try { + flutterEngine.getPlugins().add(new pl.leancode.patrol.PatrolPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin patrol, pl.leancode.patrol.PatrolPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.sentry.flutter.SentryFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sentry_flutter, io.sentry.flutter.SentryFlutterPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.csdcorp.speech_to_text.SpeechToTextPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin speech_to_text, com.csdcorp.speech_to_text.SpeechToTextPlugin", e); + } + try { + flutterEngine.getPlugins().add(new eu.simonbinder.sqlite3_flutter_libs.Sqlite3FlutterLibsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sqlite3_flutter_libs, eu.simonbinder.sqlite3_flutter_libs.Sqlite3FlutterLibsPlugin", e); + } } } diff --git a/apps/mobile/android/app/src/main/kotlin/com/example/recursor_mobile/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/example/recursor_mobile/MainActivity.kt new file mode 100644 index 0000000..b9aa7f6 --- /dev/null +++ b/apps/mobile/android/app/src/main/kotlin/com/example/recursor_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.recursor_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/profile/AndroidManifest.xml b/apps/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/build.gradle.kts b/apps/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/mobile/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/mobile/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/mobile/assets/branding/ReCursor_Darklogo.png b/apps/mobile/assets/branding/ReCursor_Darklogo.png deleted file mode 100644 index d0595dc..0000000 Binary files a/apps/mobile/assets/branding/ReCursor_Darklogo.png and /dev/null differ diff --git a/apps/mobile/assets/branding/ReCursor_Lightlogo.png b/apps/mobile/assets/branding/ReCursor_Lightlogo.png deleted file mode 100644 index 798e148..0000000 Binary files a/apps/mobile/assets/branding/ReCursor_Lightlogo.png and /dev/null differ diff --git a/apps/mobile/assets/branding/recursor_logo_dark.svg b/apps/mobile/assets/branding/recursor_logo_dark.svg new file mode 100644 index 0000000..398cc6a --- /dev/null +++ b/apps/mobile/assets/branding/recursor_logo_dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/branding/recursor_logo_light.svg b/apps/mobile/assets/branding/recursor_logo_light.svg new file mode 100644 index 0000000..e32b9f3 --- /dev/null +++ b/apps/mobile/assets/branding/recursor_logo_light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/mobile/assets/fonts/JetBrainsMono-Bold.ttf b/apps/mobile/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..cd1bee0 Binary files /dev/null and b/apps/mobile/assets/fonts/JetBrainsMono-Bold.ttf differ diff --git a/apps/mobile/assets/fonts/JetBrainsMono-Regular.ttf b/apps/mobile/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/apps/mobile/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/apps/mobile/integration_test/app_test.dart b/apps/mobile/integration_test/app_test.dart new file mode 100644 index 0000000..43f480b --- /dev/null +++ b/apps/mobile/integration_test/app_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:recursor_mobile/app.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/providers/theme_provider.dart'; +import 'package:recursor_mobile/core/providers/token_storage_provider.dart'; +import 'package:recursor_mobile/core/storage/preferences.dart'; +import 'package:recursor_mobile/core/storage/secure_token_storage.dart'; +import 'package:recursor_mobile/features/startup/domain/bridge_startup_controller.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('launch routes to bridge setup without any login workflow', ( + tester, + ) async { + final preferences = FakeAppPreferences(); + final tokenStorage = FakeSecureTokenStorage(); + final startupController = FakeBridgeStartupController( + const AppStartupResult.bridgeSetup(), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appPreferencesProvider.overrideWithValue(preferences), + tokenStorageProvider.overrideWithValue(tokenStorage), + bridgeStartupControllerProvider.overrideWithValue(startupController), + ], + child: const ReCursorApp(), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('bridgeSetupScreen')), findsOneWidget); + expect(find.text('Bridge Setup'), findsOneWidget); + expect(find.textContaining('Sign in'), findsNothing); + expect(find.textContaining('GitHub'), findsNothing); + }); +} + +class FakeBridgeStartupController extends BridgeStartupController { + FakeBridgeStartupController(this.result) + : super( + preferences: FakeAppPreferences(), + tokenStorage: FakeSecureTokenStorage(), + webSocketService: _NoopWebSocketService(), + ); + + final AppStartupResult result; + + @override + Future restore() async { + return result; + } +} + +class FakeAppPreferences extends AppPreferences { + FakeAppPreferences({this.bridgeUrl}); + + String? bridgeUrl; + + @override + String? getBridgeUrl() { + return bridgeUrl; + } + + @override + Future setBridgeUrl(String? url) async { + bridgeUrl = url; + } +} + +class FakeSecureTokenStorage extends SecureTokenStorage { + FakeSecureTokenStorage({this.token}) : super(const FlutterSecureStorage()); + + String? token; + + @override + Future getToken(String key) async { + return token; + } +} + +class _NoopWebSocketService extends WebSocketService {} diff --git a/apps/mobile/integration_test/chat_test.dart b/apps/mobile/integration_test/chat_test.dart new file mode 100644 index 0000000..e6a7b11 --- /dev/null +++ b/apps/mobile/integration_test/chat_test.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_messages.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/providers/database_provider.dart'; +import 'package:recursor_mobile/core/providers/websocket_provider.dart'; +import 'package:recursor_mobile/core/storage/database.dart'; +import 'package:recursor_mobile/features/chat/presentation/screens/session_list_screen.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('starting a chat session sends a session_start request', + (tester) async { + final database = AppDatabase.inMemory(); + final webSocketService = FakeIntegrationWebSocketService( + initialStatus: ConnectionStatus.connected, + ); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const SessionListScreen(), + ), + GoRoute( + path: '/home/chat/:sessionId', + builder: (_, state) => Scaffold( + body: Center( + child: Text('Chat ${state.pathParameters['sessionId']}'), + ), + ), + ), + ], + ); + + addTearDown(() async { + router.dispose(); + await webSocketService.close(); + await database.close(); + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + webSocketServiceProvider.overrideWithValue(webSocketService), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('New Session')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('newSessionWorkingDirectoryField')), + '/workspace/integration-project', + ); + await tester.tap(find.text('Start Session')); + await tester.pumpAndSettle(); + + expect(webSocketService.sentMessages, hasLength(1)); + expect(webSocketService.sentMessages.single.type, + BridgeMessageType.sessionStart); + expect( + webSocketService.sentMessages.single.payload['working_directory'], + '/workspace/integration-project', + ); + expect(find.textContaining('Chat '), findsOneWidget); + + final sessions = await database.select(database.sessions).get(); + expect(sessions, hasLength(1)); + expect(sessions.single.workingDirectory, '/workspace/integration-project'); + }); +} + +class FakeIntegrationWebSocketService extends WebSocketService { + FakeIntegrationWebSocketService({required ConnectionStatus initialStatus}) + : _currentStatus = initialStatus; + + final StreamController _messageController = + StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); + final List sentMessages = []; + final ConnectionStatus _currentStatus; + + @override + Stream get messages => _messageController.stream; + + @override + Stream get connectionStatus => _statusController.stream; + + @override + ConnectionStatus get currentStatus => _currentStatus; + + @override + bool send(BridgeMessage message) { + if (_currentStatus != ConnectionStatus.connected) { + return false; + } + sentMessages.add(message); + return true; + } + + Future close() async { + await _messageController.close(); + await _statusController.close(); + } +} diff --git a/apps/mobile/integration_test/patrol_test_config.dart b/apps/mobile/integration_test/patrol_test_config.dart new file mode 100644 index 0000000..6ed1edd --- /dev/null +++ b/apps/mobile/integration_test/patrol_test_config.dart @@ -0,0 +1,2 @@ +// Patrol test configuration +// Run with: flutter test integration_test/ --dart-define=PATROL=true diff --git a/apps/mobile/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/ios/Runner/GeneratedPluginRegistrant.m index efe65ec..1d16b8f 100644 --- a/apps/mobile/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/ios/Runner/GeneratedPluginRegistrant.m @@ -6,9 +6,72 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import integration_test; +#endif + +#if __has_include() +#import +#else +@import mobile_scanner; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import patrol; +#endif + +#if __has_include() +#import +#else +@import sentry_flutter; +#endif + +#if __has_include() +#import +#else +@import speech_to_text; +#endif + +#if __has_include() +#import +#else +@import sqlite3_flutter_libs; +#endif + @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [IntegrationTestPlugin registerWithRegistrar:[registry registrarForPlugin:@"IntegrationTestPlugin"]]; + [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PatrolPlugin registerWithRegistrar:[registry registrarForPlugin:@"PatrolPlugin"]]; + [SentryFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"SentryFlutterPlugin"]]; + [SpeechToTextPlugin registerWithRegistrar:[registry registrarForPlugin:@"SpeechToTextPlugin"]]; + [Sqlite3FlutterLibsPlugin registerWithRegistrar:[registry registrarForPlugin:@"Sqlite3FlutterLibsPlugin"]]; } @end diff --git a/apps/mobile/lib/app.dart b/apps/mobile/lib/app.dart new file mode 100644 index 0000000..91bc8a1 --- /dev/null +++ b/apps/mobile/lib/app.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/config/router.dart'; +import 'core/config/theme.dart'; +import 'core/providers/theme_provider.dart'; + +class ReCursorApp extends ConsumerWidget { + const ReCursorApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + final themeMode = ref.watch(themeModeProvider); + final highContrast = ref.watch(highContrastProvider); + + final effectiveDarkTheme = + highContrast ? AppTheme.highContrastTheme : AppTheme.darkTheme; + + return MaterialApp.router( + title: 'ReCursor', + theme: highContrast ? AppTheme.highContrastTheme : AppTheme.lightTheme, + darkTheme: effectiveDarkTheme, + themeMode: themeMode, + routerConfig: router, + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/apps/mobile/lib/core/config/app_config.dart b/apps/mobile/lib/core/config/app_config.dart new file mode 100644 index 0000000..1b1eb66 --- /dev/null +++ b/apps/mobile/lib/core/config/app_config.dart @@ -0,0 +1,19 @@ +/// Application-wide constants. +class AppConfig { + const AppConfig._(); + + static const String appName = 'ReCursor'; + static const String version = '0.1.0'; + + /// Heartbeat ping interval in seconds. + static const int heartbeatInterval = 15; + + /// Seconds to wait for a heartbeat pong before triggering reconnect. + static const int heartbeatTimeout = 10; + + /// Maximum number of reconnect attempts before giving up. + static const int maxReconnectAttempts = 10; + + /// Maximum number of items stored in the offline sync queue. + static const int maxSyncQueueSize = 500; +} diff --git a/apps/mobile/lib/core/config/high_contrast_theme.dart b/apps/mobile/lib/core/config/high_contrast_theme.dart new file mode 100644 index 0000000..6815fe4 --- /dev/null +++ b/apps/mobile/lib/core/config/high_contrast_theme.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +class HighContrastTheme { + static ThemeData get theme { + return ThemeData.dark().copyWith( + colorScheme: const ColorScheme.dark( + primary: Color(0xFFFFFFFF), // white + secondary: Color(0xFFFFFF00), // yellow + surface: Color(0xFF000000), // pure black + error: Color(0xFFFF0000), // pure red + ), + scaffoldBackgroundColor: Colors.black, + cardTheme: const CardThemeData( + color: Color(0xFF111111), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.white, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + elevation: 0, + shape: Border(bottom: BorderSide(color: Colors.white, width: 1)), + ), + textTheme: const TextTheme( + bodyLarge: TextStyle(color: Colors.white, fontSize: 16), + bodyMedium: TextStyle(color: Colors.white, fontSize: 14), + bodySmall: TextStyle(color: Colors.white, fontSize: 12), + titleLarge: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + iconTheme: const IconThemeData(color: Colors.white), + dividerColor: Colors.white, + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.black, + border: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.white, width: 2), + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.white, width: 2), + ), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.yellow, width: 2), + ), + labelStyle: const TextStyle(color: Colors.white), + hintStyle: const TextStyle(color: Colors.grey), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Colors.black, + selectedItemColor: Colors.yellow, + unselectedItemColor: Colors.white, + ), + ); + } +} diff --git a/apps/mobile/lib/core/config/router.dart b/apps/mobile/lib/core/config/router.dart new file mode 100644 index 0000000..e5c7704 --- /dev/null +++ b/apps/mobile/lib/core/config/router.dart @@ -0,0 +1,130 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/agents/presentation/screens/agent_list_screen.dart'; +import '../../features/approvals/presentation/screens/approval_detail_screen.dart'; +import '../../features/approvals/presentation/screens/approvals_screen.dart'; +import '../../features/chat/presentation/screens/chat_screen.dart'; +import '../../features/chat/presentation/screens/session_list_screen.dart'; +import '../../features/diff/presentation/screens/diff_viewer_screen.dart'; +import '../../features/git/presentation/screens/git_screen.dart'; +import '../../features/home/home_shell.dart'; +import '../../features/repos/presentation/screens/file_tree_screen.dart'; +import '../../features/repos/presentation/screens/file_viewer_screen.dart'; +import '../../features/settings/presentation/screens/settings_screen.dart'; +import '../../features/startup/presentation/screens/bridge_setup_screen.dart'; +import '../../features/startup/presentation/screens/splash_screen.dart'; +import '../../features/terminal/presentation/screens/terminal_screen.dart'; + +GoRouter _buildRouter() { + return GoRouter( + initialLocation: '/splash', + routes: [ + GoRoute(path: '/', redirect: (_, __) => '/splash'), + GoRoute( + path: '/splash', + builder: (_, __) => const SplashScreen(), + ), + GoRoute( + path: '/bridge-setup', + builder: (_, __) => const BridgeSetupScreen(), + ), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => + HomeShell(navigationShell: navigationShell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/chat', + builder: (_, __) => const SessionListScreen(), + routes: [ + GoRoute( + path: ':sessionId', + builder: (_, state) => ChatScreen( + sessionId: state.pathParameters['sessionId']!, + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/diff', + builder: (_, __) => const DiffViewerScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/repos', + builder: (_, state) { + final sessionId = + state.uri.queryParameters['sessionId'] ?? ''; + return FileTreeScreen(sessionId: sessionId); + }, + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/git', + builder: (_, __) => const GitScreen(sessionId: ''), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/approvals', + builder: (_, __) => const ApprovalsScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home/settings', + builder: (_, __) => const SettingsScreen(), + ), + ], + ), + ], + ), + GoRoute( + path: '/home/agents', + builder: (_, __) => const AgentListScreen(), + ), + GoRoute( + path: '/approval/:id', + builder: (_, state) => + ApprovalDetailScreen(toolCallId: state.pathParameters['id']!), + ), + GoRoute( + path: '/terminal', + builder: (_, __) => + const TerminalScreen(sessionId: 'default', workingDirectory: '~'), + ), + GoRoute( + path: '/home/repos/view', + builder: (_, state) { + final extra = state.extra as Map? ?? {}; + return FileViewerScreen( + sessionId: extra['sessionId'] ?? '', + path: extra['path'] ?? '', + ); + }, + ), + ], + ); +} + +final routerProvider = Provider((ref) { + return _buildRouter(); +}); + +final appRouter = Provider((ref) => ref.watch(routerProvider)); diff --git a/apps/mobile/lib/core/config/theme.dart b/apps/mobile/lib/core/config/theme.dart new file mode 100644 index 0000000..59b6a98 --- /dev/null +++ b/apps/mobile/lib/core/config/theme.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +import 'high_contrast_theme.dart'; + +class AppColors { + static const background = Color(0xFF121212); + static const surface = Color(0xFF1E1E1E); + static const surfaceVariant = Color(0xFF252526); + static const primary = Color(0xFF569CD6); + static const secondary = Color(0xFF4EC9B0); + static const error = Color(0xFFF44747); + static const added = Color(0xFF4EC9B0); + static const removed = Color(0xFFF44747); + static const accent = Color(0xFFCE9178); + static const textPrimary = Color(0xFFD4D4D4); + static const textSecondary = Color(0xFF9E9E9E); + static const border = Color(0xFF3E3E3E); +} + +class AppTheme { + static ThemeData get darkTheme { + const colorScheme = ColorScheme.dark( + surface: AppColors.surface, + background: AppColors.background, + primary: AppColors.primary, + secondary: AppColors.secondary, + error: AppColors.error, + onSurface: AppColors.textPrimary, + onBackground: AppColors.textPrimary, + onPrimary: Colors.white, + onSecondary: Colors.white, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: AppColors.background, + cardColor: AppColors.surfaceVariant, + cardTheme: const CardThemeData( + color: AppColors.surfaceVariant, + elevation: 2, + margin: EdgeInsets.zero, + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.surface, + elevation: 0, + foregroundColor: AppColors.textPrimary, + titleTextStyle: TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + iconTheme: IconThemeData(color: AppColors.textPrimary), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: AppColors.surface, + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textSecondary, + type: BottomNavigationBarType.fixed, + elevation: 0, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.error), + ), + hintStyle: const TextStyle(color: AppColors.textSecondary), + labelStyle: const TextStyle(color: AppColors.textSecondary), + ), + textTheme: const TextTheme( + bodyMedium: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 14, + color: AppColors.textPrimary, + ), + bodyLarge: TextStyle( + fontSize: 16, + color: AppColors.textPrimary, + ), + bodySmall: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + titleLarge: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + titleSmall: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + labelMedium: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + iconTheme: const IconThemeData(color: AppColors.textPrimary), + dividerTheme: const DividerThemeData( + color: AppColors.border, + thickness: 1, + ), + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) return AppColors.primary; + return AppColors.textSecondary; + }), + trackColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return AppColors.primary.withOpacity(0.4); + } + return AppColors.border; + }), + ), + ); + } + + static ThemeData get highContrastTheme => HighContrastTheme.theme; + + static ThemeData get lightTheme { + const colorScheme = ColorScheme.light( + primary: AppColors.primary, + secondary: AppColors.secondary, + error: AppColors.error, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + cardTheme: const CardThemeData(elevation: 2), + appBarTheme: const AppBarTheme(elevation: 0), + inputDecorationTheme: InputDecorationTheme( + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/core/models/agent_models.dart b/apps/mobile/lib/core/models/agent_models.dart new file mode 100644 index 0000000..7e0a823 --- /dev/null +++ b/apps/mobile/lib/core/models/agent_models.dart @@ -0,0 +1,27 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'agent_models.freezed.dart'; +part 'agent_models.g.dart'; + +enum AgentType { claudeCode, openCode, aider, goose, custom } + +enum AgentConnectionStatus { connected, disconnected, inactive } + +@freezed +class AgentConfig with _$AgentConfig { + const factory AgentConfig({ + required String id, + required String displayName, + required AgentType type, + required String bridgeUrl, + required String authToken, + String? workingDirectory, + @Default(AgentConnectionStatus.disconnected) AgentConnectionStatus status, + DateTime? lastConnectedAt, + required DateTime createdAt, + required DateTime updatedAt, + }) = _AgentConfig; + + factory AgentConfig.fromJson(Map json) => + _$AgentConfigFromJson(json); +} diff --git a/apps/mobile/lib/core/models/agent_models.freezed.dart b/apps/mobile/lib/core/models/agent_models.freezed.dart new file mode 100644 index 0000000..4b69590 --- /dev/null +++ b/apps/mobile/lib/core/models/agent_models.freezed.dart @@ -0,0 +1,367 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'agent_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +AgentConfig _$AgentConfigFromJson(Map json) { + return _AgentConfig.fromJson(json); +} + +/// @nodoc +mixin _$AgentConfig { + String get id => throw _privateConstructorUsedError; + String get displayName => throw _privateConstructorUsedError; + AgentType get type => throw _privateConstructorUsedError; + String get bridgeUrl => throw _privateConstructorUsedError; + String get authToken => throw _privateConstructorUsedError; + String? get workingDirectory => throw _privateConstructorUsedError; + AgentConnectionStatus get status => throw _privateConstructorUsedError; + DateTime? get lastConnectedAt => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime get updatedAt => throw _privateConstructorUsedError; + + /// Serializes this AgentConfig to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AgentConfig + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AgentConfigCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AgentConfigCopyWith<$Res> { + factory $AgentConfigCopyWith( + AgentConfig value, $Res Function(AgentConfig) then) = + _$AgentConfigCopyWithImpl<$Res, AgentConfig>; + @useResult + $Res call( + {String id, + String displayName, + AgentType type, + String bridgeUrl, + String authToken, + String? workingDirectory, + AgentConnectionStatus status, + DateTime? lastConnectedAt, + DateTime createdAt, + DateTime updatedAt}); +} + +/// @nodoc +class _$AgentConfigCopyWithImpl<$Res, $Val extends AgentConfig> + implements $AgentConfigCopyWith<$Res> { + _$AgentConfigCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AgentConfig + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? displayName = null, + Object? type = null, + Object? bridgeUrl = null, + Object? authToken = null, + Object? workingDirectory = freezed, + Object? status = null, + Object? lastConnectedAt = freezed, + Object? createdAt = null, + Object? updatedAt = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + displayName: null == displayName + ? _value.displayName + : displayName // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as AgentType, + bridgeUrl: null == bridgeUrl + ? _value.bridgeUrl + : bridgeUrl // ignore: cast_nullable_to_non_nullable + as String, + authToken: null == authToken + ? _value.authToken + : authToken // ignore: cast_nullable_to_non_nullable + as String, + workingDirectory: freezed == workingDirectory + ? _value.workingDirectory + : workingDirectory // ignore: cast_nullable_to_non_nullable + as String?, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AgentConnectionStatus, + lastConnectedAt: freezed == lastConnectedAt + ? _value.lastConnectedAt + : lastConnectedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AgentConfigImplCopyWith<$Res> + implements $AgentConfigCopyWith<$Res> { + factory _$$AgentConfigImplCopyWith( + _$AgentConfigImpl value, $Res Function(_$AgentConfigImpl) then) = + __$$AgentConfigImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String displayName, + AgentType type, + String bridgeUrl, + String authToken, + String? workingDirectory, + AgentConnectionStatus status, + DateTime? lastConnectedAt, + DateTime createdAt, + DateTime updatedAt}); +} + +/// @nodoc +class __$$AgentConfigImplCopyWithImpl<$Res> + extends _$AgentConfigCopyWithImpl<$Res, _$AgentConfigImpl> + implements _$$AgentConfigImplCopyWith<$Res> { + __$$AgentConfigImplCopyWithImpl( + _$AgentConfigImpl _value, $Res Function(_$AgentConfigImpl) _then) + : super(_value, _then); + + /// Create a copy of AgentConfig + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? displayName = null, + Object? type = null, + Object? bridgeUrl = null, + Object? authToken = null, + Object? workingDirectory = freezed, + Object? status = null, + Object? lastConnectedAt = freezed, + Object? createdAt = null, + Object? updatedAt = null, + }) { + return _then(_$AgentConfigImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + displayName: null == displayName + ? _value.displayName + : displayName // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as AgentType, + bridgeUrl: null == bridgeUrl + ? _value.bridgeUrl + : bridgeUrl // ignore: cast_nullable_to_non_nullable + as String, + authToken: null == authToken + ? _value.authToken + : authToken // ignore: cast_nullable_to_non_nullable + as String, + workingDirectory: freezed == workingDirectory + ? _value.workingDirectory + : workingDirectory // ignore: cast_nullable_to_non_nullable + as String?, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as AgentConnectionStatus, + lastConnectedAt: freezed == lastConnectedAt + ? _value.lastConnectedAt + : lastConnectedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: null == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$AgentConfigImpl implements _AgentConfig { + const _$AgentConfigImpl( + {required this.id, + required this.displayName, + required this.type, + required this.bridgeUrl, + required this.authToken, + this.workingDirectory, + this.status = AgentConnectionStatus.disconnected, + this.lastConnectedAt, + required this.createdAt, + required this.updatedAt}); + + factory _$AgentConfigImpl.fromJson(Map json) => + _$$AgentConfigImplFromJson(json); + + @override + final String id; + @override + final String displayName; + @override + final AgentType type; + @override + final String bridgeUrl; + @override + final String authToken; + @override + final String? workingDirectory; + @override + @JsonKey() + final AgentConnectionStatus status; + @override + final DateTime? lastConnectedAt; + @override + final DateTime createdAt; + @override + final DateTime updatedAt; + + @override + String toString() { + return 'AgentConfig(id: $id, displayName: $displayName, type: $type, bridgeUrl: $bridgeUrl, authToken: $authToken, workingDirectory: $workingDirectory, status: $status, lastConnectedAt: $lastConnectedAt, createdAt: $createdAt, updatedAt: $updatedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AgentConfigImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.displayName, displayName) || + other.displayName == displayName) && + (identical(other.type, type) || other.type == type) && + (identical(other.bridgeUrl, bridgeUrl) || + other.bridgeUrl == bridgeUrl) && + (identical(other.authToken, authToken) || + other.authToken == authToken) && + (identical(other.workingDirectory, workingDirectory) || + other.workingDirectory == workingDirectory) && + (identical(other.status, status) || other.status == status) && + (identical(other.lastConnectedAt, lastConnectedAt) || + other.lastConnectedAt == lastConnectedAt) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + displayName, + type, + bridgeUrl, + authToken, + workingDirectory, + status, + lastConnectedAt, + createdAt, + updatedAt); + + /// Create a copy of AgentConfig + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AgentConfigImplCopyWith<_$AgentConfigImpl> get copyWith => + __$$AgentConfigImplCopyWithImpl<_$AgentConfigImpl>(this, _$identity); + + @override + Map toJson() { + return _$$AgentConfigImplToJson( + this, + ); + } +} + +abstract class _AgentConfig implements AgentConfig { + const factory _AgentConfig( + {required final String id, + required final String displayName, + required final AgentType type, + required final String bridgeUrl, + required final String authToken, + final String? workingDirectory, + final AgentConnectionStatus status, + final DateTime? lastConnectedAt, + required final DateTime createdAt, + required final DateTime updatedAt}) = _$AgentConfigImpl; + + factory _AgentConfig.fromJson(Map json) = + _$AgentConfigImpl.fromJson; + + @override + String get id; + @override + String get displayName; + @override + AgentType get type; + @override + String get bridgeUrl; + @override + String get authToken; + @override + String? get workingDirectory; + @override + AgentConnectionStatus get status; + @override + DateTime? get lastConnectedAt; + @override + DateTime get createdAt; + @override + DateTime get updatedAt; + + /// Create a copy of AgentConfig + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AgentConfigImplCopyWith<_$AgentConfigImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/agent_models.g.dart b/apps/mobile/lib/core/models/agent_models.g.dart new file mode 100644 index 0000000..0589662 --- /dev/null +++ b/apps/mobile/lib/core/models/agent_models.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'agent_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AgentConfigImpl _$$AgentConfigImplFromJson(Map json) => + _$AgentConfigImpl( + id: json['id'] as String, + displayName: json['displayName'] as String, + type: $enumDecode(_$AgentTypeEnumMap, json['type']), + bridgeUrl: json['bridgeUrl'] as String, + authToken: json['authToken'] as String, + workingDirectory: json['workingDirectory'] as String?, + status: + $enumDecodeNullable(_$AgentConnectionStatusEnumMap, json['status']) ?? + AgentConnectionStatus.disconnected, + lastConnectedAt: json['lastConnectedAt'] == null + ? null + : DateTime.parse(json['lastConnectedAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + +Map _$$AgentConfigImplToJson(_$AgentConfigImpl instance) => + { + 'id': instance.id, + 'displayName': instance.displayName, + 'type': _$AgentTypeEnumMap[instance.type]!, + 'bridgeUrl': instance.bridgeUrl, + 'authToken': instance.authToken, + 'workingDirectory': instance.workingDirectory, + 'status': _$AgentConnectionStatusEnumMap[instance.status]!, + 'lastConnectedAt': instance.lastConnectedAt?.toIso8601String(), + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt.toIso8601String(), + }; + +const _$AgentTypeEnumMap = { + AgentType.claudeCode: 'claudeCode', + AgentType.openCode: 'openCode', + AgentType.aider: 'aider', + AgentType.goose: 'goose', + AgentType.custom: 'custom', +}; + +const _$AgentConnectionStatusEnumMap = { + AgentConnectionStatus.connected: 'connected', + AgentConnectionStatus.disconnected: 'disconnected', + AgentConnectionStatus.inactive: 'inactive', +}; diff --git a/apps/mobile/lib/core/models/file_models.dart b/apps/mobile/lib/core/models/file_models.dart new file mode 100644 index 0000000..732cf52 --- /dev/null +++ b/apps/mobile/lib/core/models/file_models.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'file_models.freezed.dart'; +part 'file_models.g.dart'; + +enum FileNodeType { file, directory } + +@freezed +class FileTreeNode with _$FileTreeNode { + const factory FileTreeNode({ + required String name, + required String path, + required FileNodeType type, + List? children, + int? size, + DateTime? modifiedAt, + String? content, + }) = _FileTreeNode; + + factory FileTreeNode.fromJson(Map json) => + _$FileTreeNodeFromJson(json); +} diff --git a/apps/mobile/lib/core/models/file_models.freezed.dart b/apps/mobile/lib/core/models/file_models.freezed.dart new file mode 100644 index 0000000..b68bac3 --- /dev/null +++ b/apps/mobile/lib/core/models/file_models.freezed.dart @@ -0,0 +1,306 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'file_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +FileTreeNode _$FileTreeNodeFromJson(Map json) { + return _FileTreeNode.fromJson(json); +} + +/// @nodoc +mixin _$FileTreeNode { + String get name => throw _privateConstructorUsedError; + String get path => throw _privateConstructorUsedError; + FileNodeType get type => throw _privateConstructorUsedError; + List? get children => throw _privateConstructorUsedError; + int? get size => throw _privateConstructorUsedError; + DateTime? get modifiedAt => throw _privateConstructorUsedError; + String? get content => throw _privateConstructorUsedError; + + /// Serializes this FileTreeNode to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of FileTreeNode + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $FileTreeNodeCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $FileTreeNodeCopyWith<$Res> { + factory $FileTreeNodeCopyWith( + FileTreeNode value, $Res Function(FileTreeNode) then) = + _$FileTreeNodeCopyWithImpl<$Res, FileTreeNode>; + @useResult + $Res call( + {String name, + String path, + FileNodeType type, + List? children, + int? size, + DateTime? modifiedAt, + String? content}); +} + +/// @nodoc +class _$FileTreeNodeCopyWithImpl<$Res, $Val extends FileTreeNode> + implements $FileTreeNodeCopyWith<$Res> { + _$FileTreeNodeCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of FileTreeNode + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? path = null, + Object? type = null, + Object? children = freezed, + Object? size = freezed, + Object? modifiedAt = freezed, + Object? content = freezed, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as FileNodeType, + children: freezed == children + ? _value.children + : children // ignore: cast_nullable_to_non_nullable + as List?, + size: freezed == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int?, + modifiedAt: freezed == modifiedAt + ? _value.modifiedAt + : modifiedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + content: freezed == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$FileTreeNodeImplCopyWith<$Res> + implements $FileTreeNodeCopyWith<$Res> { + factory _$$FileTreeNodeImplCopyWith( + _$FileTreeNodeImpl value, $Res Function(_$FileTreeNodeImpl) then) = + __$$FileTreeNodeImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String name, + String path, + FileNodeType type, + List? children, + int? size, + DateTime? modifiedAt, + String? content}); +} + +/// @nodoc +class __$$FileTreeNodeImplCopyWithImpl<$Res> + extends _$FileTreeNodeCopyWithImpl<$Res, _$FileTreeNodeImpl> + implements _$$FileTreeNodeImplCopyWith<$Res> { + __$$FileTreeNodeImplCopyWithImpl( + _$FileTreeNodeImpl _value, $Res Function(_$FileTreeNodeImpl) _then) + : super(_value, _then); + + /// Create a copy of FileTreeNode + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? path = null, + Object? type = null, + Object? children = freezed, + Object? size = freezed, + Object? modifiedAt = freezed, + Object? content = freezed, + }) { + return _then(_$FileTreeNodeImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as FileNodeType, + children: freezed == children + ? _value._children + : children // ignore: cast_nullable_to_non_nullable + as List?, + size: freezed == size + ? _value.size + : size // ignore: cast_nullable_to_non_nullable + as int?, + modifiedAt: freezed == modifiedAt + ? _value.modifiedAt + : modifiedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + content: freezed == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$FileTreeNodeImpl implements _FileTreeNode { + const _$FileTreeNodeImpl( + {required this.name, + required this.path, + required this.type, + final List? children, + this.size, + this.modifiedAt, + this.content}) + : _children = children; + + factory _$FileTreeNodeImpl.fromJson(Map json) => + _$$FileTreeNodeImplFromJson(json); + + @override + final String name; + @override + final String path; + @override + final FileNodeType type; + final List? _children; + @override + List? get children { + final value = _children; + if (value == null) return null; + if (_children is EqualUnmodifiableListView) return _children; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + final int? size; + @override + final DateTime? modifiedAt; + @override + final String? content; + + @override + String toString() { + return 'FileTreeNode(name: $name, path: $path, type: $type, children: $children, size: $size, modifiedAt: $modifiedAt, content: $content)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$FileTreeNodeImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.path, path) || other.path == path) && + (identical(other.type, type) || other.type == type) && + const DeepCollectionEquality().equals(other._children, _children) && + (identical(other.size, size) || other.size == size) && + (identical(other.modifiedAt, modifiedAt) || + other.modifiedAt == modifiedAt) && + (identical(other.content, content) || other.content == content)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + name, + path, + type, + const DeepCollectionEquality().hash(_children), + size, + modifiedAt, + content); + + /// Create a copy of FileTreeNode + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$FileTreeNodeImplCopyWith<_$FileTreeNodeImpl> get copyWith => + __$$FileTreeNodeImplCopyWithImpl<_$FileTreeNodeImpl>(this, _$identity); + + @override + Map toJson() { + return _$$FileTreeNodeImplToJson( + this, + ); + } +} + +abstract class _FileTreeNode implements FileTreeNode { + const factory _FileTreeNode( + {required final String name, + required final String path, + required final FileNodeType type, + final List? children, + final int? size, + final DateTime? modifiedAt, + final String? content}) = _$FileTreeNodeImpl; + + factory _FileTreeNode.fromJson(Map json) = + _$FileTreeNodeImpl.fromJson; + + @override + String get name; + @override + String get path; + @override + FileNodeType get type; + @override + List? get children; + @override + int? get size; + @override + DateTime? get modifiedAt; + @override + String? get content; + + /// Create a copy of FileTreeNode + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$FileTreeNodeImplCopyWith<_$FileTreeNodeImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/file_models.g.dart b/apps/mobile/lib/core/models/file_models.g.dart new file mode 100644 index 0000000..d85d2ec --- /dev/null +++ b/apps/mobile/lib/core/models/file_models.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$FileTreeNodeImpl _$$FileTreeNodeImplFromJson(Map json) => + _$FileTreeNodeImpl( + name: json['name'] as String, + path: json['path'] as String, + type: $enumDecode(_$FileNodeTypeEnumMap, json['type']), + children: (json['children'] as List?) + ?.map((e) => FileTreeNode.fromJson(e as Map)) + .toList(), + size: (json['size'] as num?)?.toInt(), + modifiedAt: json['modifiedAt'] == null + ? null + : DateTime.parse(json['modifiedAt'] as String), + content: json['content'] as String?, + ); + +Map _$$FileTreeNodeImplToJson(_$FileTreeNodeImpl instance) => + { + 'name': instance.name, + 'path': instance.path, + 'type': _$FileNodeTypeEnumMap[instance.type]!, + 'children': instance.children, + 'size': instance.size, + 'modifiedAt': instance.modifiedAt?.toIso8601String(), + 'content': instance.content, + }; + +const _$FileNodeTypeEnumMap = { + FileNodeType.file: 'file', + FileNodeType.directory: 'directory', +}; diff --git a/apps/mobile/lib/core/models/git_models.dart b/apps/mobile/lib/core/models/git_models.dart new file mode 100644 index 0000000..17a66da --- /dev/null +++ b/apps/mobile/lib/core/models/git_models.dart @@ -0,0 +1,96 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'git_models.freezed.dart'; +part 'git_models.g.dart'; + +enum FileChangeStatus { modified, added, deleted, untracked, renamed } + +enum DiffLineType { context, added, removed } + +@freezed +class GitStatus with _$GitStatus { + const factory GitStatus({ + required String branch, + required List changes, + required int ahead, + required int behind, + required bool isClean, + }) = _GitStatus; + + factory GitStatus.fromJson(Map json) => + _$GitStatusFromJson(json); +} + +@freezed +class GitFileChange with _$GitFileChange { + const factory GitFileChange({ + required String path, + required FileChangeStatus status, + int? additions, + int? deletions, + String? diff, + }) = _GitFileChange; + + factory GitFileChange.fromJson(Map json) => + _$GitFileChangeFromJson(json); +} + +@freezed +class GitBranch with _$GitBranch { + const factory GitBranch({ + required String name, + required bool isCurrent, + String? upstream, + int? ahead, + int? behind, + }) = _GitBranch; + + factory GitBranch.fromJson(Map json) => + _$GitBranchFromJson(json); +} + +@freezed +class DiffFile with _$DiffFile { + const factory DiffFile({ + required String path, + required String oldPath, + required String newPath, + required FileChangeStatus status, + required int additions, + required int deletions, + required List hunks, + String? oldMode, + String? newMode, + }) = _DiffFile; + + factory DiffFile.fromJson(Map json) => + _$DiffFileFromJson(json); +} + +@freezed +class DiffHunk with _$DiffHunk { + const factory DiffHunk({ + required String header, + required int oldStart, + required int oldLines, + required int newStart, + required int newLines, + required List lines, + }) = _DiffHunk; + + factory DiffHunk.fromJson(Map json) => + _$DiffHunkFromJson(json); +} + +@freezed +class DiffLine with _$DiffLine { + const factory DiffLine({ + required DiffLineType type, + required String content, + int? oldLineNumber, + int? newLineNumber, + }) = _DiffLine; + + factory DiffLine.fromJson(Map json) => + _$DiffLineFromJson(json); +} diff --git a/apps/mobile/lib/core/models/git_models.freezed.dart b/apps/mobile/lib/core/models/git_models.freezed.dart new file mode 100644 index 0000000..50d1d10 --- /dev/null +++ b/apps/mobile/lib/core/models/git_models.freezed.dart @@ -0,0 +1,1527 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'git_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +GitStatus _$GitStatusFromJson(Map json) { + return _GitStatus.fromJson(json); +} + +/// @nodoc +mixin _$GitStatus { + String get branch => throw _privateConstructorUsedError; + List get changes => throw _privateConstructorUsedError; + int get ahead => throw _privateConstructorUsedError; + int get behind => throw _privateConstructorUsedError; + bool get isClean => throw _privateConstructorUsedError; + + /// Serializes this GitStatus to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of GitStatus + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GitStatusCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GitStatusCopyWith<$Res> { + factory $GitStatusCopyWith(GitStatus value, $Res Function(GitStatus) then) = + _$GitStatusCopyWithImpl<$Res, GitStatus>; + @useResult + $Res call( + {String branch, + List changes, + int ahead, + int behind, + bool isClean}); +} + +/// @nodoc +class _$GitStatusCopyWithImpl<$Res, $Val extends GitStatus> + implements $GitStatusCopyWith<$Res> { + _$GitStatusCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GitStatus + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? branch = null, + Object? changes = null, + Object? ahead = null, + Object? behind = null, + Object? isClean = null, + }) { + return _then(_value.copyWith( + branch: null == branch + ? _value.branch + : branch // ignore: cast_nullable_to_non_nullable + as String, + changes: null == changes + ? _value.changes + : changes // ignore: cast_nullable_to_non_nullable + as List, + ahead: null == ahead + ? _value.ahead + : ahead // ignore: cast_nullable_to_non_nullable + as int, + behind: null == behind + ? _value.behind + : behind // ignore: cast_nullable_to_non_nullable + as int, + isClean: null == isClean + ? _value.isClean + : isClean // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$GitStatusImplCopyWith<$Res> + implements $GitStatusCopyWith<$Res> { + factory _$$GitStatusImplCopyWith( + _$GitStatusImpl value, $Res Function(_$GitStatusImpl) then) = + __$$GitStatusImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String branch, + List changes, + int ahead, + int behind, + bool isClean}); +} + +/// @nodoc +class __$$GitStatusImplCopyWithImpl<$Res> + extends _$GitStatusCopyWithImpl<$Res, _$GitStatusImpl> + implements _$$GitStatusImplCopyWith<$Res> { + __$$GitStatusImplCopyWithImpl( + _$GitStatusImpl _value, $Res Function(_$GitStatusImpl) _then) + : super(_value, _then); + + /// Create a copy of GitStatus + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? branch = null, + Object? changes = null, + Object? ahead = null, + Object? behind = null, + Object? isClean = null, + }) { + return _then(_$GitStatusImpl( + branch: null == branch + ? _value.branch + : branch // ignore: cast_nullable_to_non_nullable + as String, + changes: null == changes + ? _value._changes + : changes // ignore: cast_nullable_to_non_nullable + as List, + ahead: null == ahead + ? _value.ahead + : ahead // ignore: cast_nullable_to_non_nullable + as int, + behind: null == behind + ? _value.behind + : behind // ignore: cast_nullable_to_non_nullable + as int, + isClean: null == isClean + ? _value.isClean + : isClean // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$GitStatusImpl implements _GitStatus { + const _$GitStatusImpl( + {required this.branch, + required final List changes, + required this.ahead, + required this.behind, + required this.isClean}) + : _changes = changes; + + factory _$GitStatusImpl.fromJson(Map json) => + _$$GitStatusImplFromJson(json); + + @override + final String branch; + final List _changes; + @override + List get changes { + if (_changes is EqualUnmodifiableListView) return _changes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_changes); + } + + @override + final int ahead; + @override + final int behind; + @override + final bool isClean; + + @override + String toString() { + return 'GitStatus(branch: $branch, changes: $changes, ahead: $ahead, behind: $behind, isClean: $isClean)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GitStatusImpl && + (identical(other.branch, branch) || other.branch == branch) && + const DeepCollectionEquality().equals(other._changes, _changes) && + (identical(other.ahead, ahead) || other.ahead == ahead) && + (identical(other.behind, behind) || other.behind == behind) && + (identical(other.isClean, isClean) || other.isClean == isClean)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, branch, + const DeepCollectionEquality().hash(_changes), ahead, behind, isClean); + + /// Create a copy of GitStatus + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GitStatusImplCopyWith<_$GitStatusImpl> get copyWith => + __$$GitStatusImplCopyWithImpl<_$GitStatusImpl>(this, _$identity); + + @override + Map toJson() { + return _$$GitStatusImplToJson( + this, + ); + } +} + +abstract class _GitStatus implements GitStatus { + const factory _GitStatus( + {required final String branch, + required final List changes, + required final int ahead, + required final int behind, + required final bool isClean}) = _$GitStatusImpl; + + factory _GitStatus.fromJson(Map json) = + _$GitStatusImpl.fromJson; + + @override + String get branch; + @override + List get changes; + @override + int get ahead; + @override + int get behind; + @override + bool get isClean; + + /// Create a copy of GitStatus + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GitStatusImplCopyWith<_$GitStatusImpl> get copyWith => + throw _privateConstructorUsedError; +} + +GitFileChange _$GitFileChangeFromJson(Map json) { + return _GitFileChange.fromJson(json); +} + +/// @nodoc +mixin _$GitFileChange { + String get path => throw _privateConstructorUsedError; + FileChangeStatus get status => throw _privateConstructorUsedError; + int? get additions => throw _privateConstructorUsedError; + int? get deletions => throw _privateConstructorUsedError; + String? get diff => throw _privateConstructorUsedError; + + /// Serializes this GitFileChange to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of GitFileChange + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GitFileChangeCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GitFileChangeCopyWith<$Res> { + factory $GitFileChangeCopyWith( + GitFileChange value, $Res Function(GitFileChange) then) = + _$GitFileChangeCopyWithImpl<$Res, GitFileChange>; + @useResult + $Res call( + {String path, + FileChangeStatus status, + int? additions, + int? deletions, + String? diff}); +} + +/// @nodoc +class _$GitFileChangeCopyWithImpl<$Res, $Val extends GitFileChange> + implements $GitFileChangeCopyWith<$Res> { + _$GitFileChangeCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GitFileChange + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? status = null, + Object? additions = freezed, + Object? deletions = freezed, + Object? diff = freezed, + }) { + return _then(_value.copyWith( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as FileChangeStatus, + additions: freezed == additions + ? _value.additions + : additions // ignore: cast_nullable_to_non_nullable + as int?, + deletions: freezed == deletions + ? _value.deletions + : deletions // ignore: cast_nullable_to_non_nullable + as int?, + diff: freezed == diff + ? _value.diff + : diff // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$GitFileChangeImplCopyWith<$Res> + implements $GitFileChangeCopyWith<$Res> { + factory _$$GitFileChangeImplCopyWith( + _$GitFileChangeImpl value, $Res Function(_$GitFileChangeImpl) then) = + __$$GitFileChangeImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String path, + FileChangeStatus status, + int? additions, + int? deletions, + String? diff}); +} + +/// @nodoc +class __$$GitFileChangeImplCopyWithImpl<$Res> + extends _$GitFileChangeCopyWithImpl<$Res, _$GitFileChangeImpl> + implements _$$GitFileChangeImplCopyWith<$Res> { + __$$GitFileChangeImplCopyWithImpl( + _$GitFileChangeImpl _value, $Res Function(_$GitFileChangeImpl) _then) + : super(_value, _then); + + /// Create a copy of GitFileChange + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? status = null, + Object? additions = freezed, + Object? deletions = freezed, + Object? diff = freezed, + }) { + return _then(_$GitFileChangeImpl( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as FileChangeStatus, + additions: freezed == additions + ? _value.additions + : additions // ignore: cast_nullable_to_non_nullable + as int?, + deletions: freezed == deletions + ? _value.deletions + : deletions // ignore: cast_nullable_to_non_nullable + as int?, + diff: freezed == diff + ? _value.diff + : diff // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$GitFileChangeImpl implements _GitFileChange { + const _$GitFileChangeImpl( + {required this.path, + required this.status, + this.additions, + this.deletions, + this.diff}); + + factory _$GitFileChangeImpl.fromJson(Map json) => + _$$GitFileChangeImplFromJson(json); + + @override + final String path; + @override + final FileChangeStatus status; + @override + final int? additions; + @override + final int? deletions; + @override + final String? diff; + + @override + String toString() { + return 'GitFileChange(path: $path, status: $status, additions: $additions, deletions: $deletions, diff: $diff)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GitFileChangeImpl && + (identical(other.path, path) || other.path == path) && + (identical(other.status, status) || other.status == status) && + (identical(other.additions, additions) || + other.additions == additions) && + (identical(other.deletions, deletions) || + other.deletions == deletions) && + (identical(other.diff, diff) || other.diff == diff)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, path, status, additions, deletions, diff); + + /// Create a copy of GitFileChange + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GitFileChangeImplCopyWith<_$GitFileChangeImpl> get copyWith => + __$$GitFileChangeImplCopyWithImpl<_$GitFileChangeImpl>(this, _$identity); + + @override + Map toJson() { + return _$$GitFileChangeImplToJson( + this, + ); + } +} + +abstract class _GitFileChange implements GitFileChange { + const factory _GitFileChange( + {required final String path, + required final FileChangeStatus status, + final int? additions, + final int? deletions, + final String? diff}) = _$GitFileChangeImpl; + + factory _GitFileChange.fromJson(Map json) = + _$GitFileChangeImpl.fromJson; + + @override + String get path; + @override + FileChangeStatus get status; + @override + int? get additions; + @override + int? get deletions; + @override + String? get diff; + + /// Create a copy of GitFileChange + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GitFileChangeImplCopyWith<_$GitFileChangeImpl> get copyWith => + throw _privateConstructorUsedError; +} + +GitBranch _$GitBranchFromJson(Map json) { + return _GitBranch.fromJson(json); +} + +/// @nodoc +mixin _$GitBranch { + String get name => throw _privateConstructorUsedError; + bool get isCurrent => throw _privateConstructorUsedError; + String? get upstream => throw _privateConstructorUsedError; + int? get ahead => throw _privateConstructorUsedError; + int? get behind => throw _privateConstructorUsedError; + + /// Serializes this GitBranch to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of GitBranch + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $GitBranchCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GitBranchCopyWith<$Res> { + factory $GitBranchCopyWith(GitBranch value, $Res Function(GitBranch) then) = + _$GitBranchCopyWithImpl<$Res, GitBranch>; + @useResult + $Res call( + {String name, bool isCurrent, String? upstream, int? ahead, int? behind}); +} + +/// @nodoc +class _$GitBranchCopyWithImpl<$Res, $Val extends GitBranch> + implements $GitBranchCopyWith<$Res> { + _$GitBranchCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of GitBranch + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? isCurrent = null, + Object? upstream = freezed, + Object? ahead = freezed, + Object? behind = freezed, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + isCurrent: null == isCurrent + ? _value.isCurrent + : isCurrent // ignore: cast_nullable_to_non_nullable + as bool, + upstream: freezed == upstream + ? _value.upstream + : upstream // ignore: cast_nullable_to_non_nullable + as String?, + ahead: freezed == ahead + ? _value.ahead + : ahead // ignore: cast_nullable_to_non_nullable + as int?, + behind: freezed == behind + ? _value.behind + : behind // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$GitBranchImplCopyWith<$Res> + implements $GitBranchCopyWith<$Res> { + factory _$$GitBranchImplCopyWith( + _$GitBranchImpl value, $Res Function(_$GitBranchImpl) then) = + __$$GitBranchImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String name, bool isCurrent, String? upstream, int? ahead, int? behind}); +} + +/// @nodoc +class __$$GitBranchImplCopyWithImpl<$Res> + extends _$GitBranchCopyWithImpl<$Res, _$GitBranchImpl> + implements _$$GitBranchImplCopyWith<$Res> { + __$$GitBranchImplCopyWithImpl( + _$GitBranchImpl _value, $Res Function(_$GitBranchImpl) _then) + : super(_value, _then); + + /// Create a copy of GitBranch + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? isCurrent = null, + Object? upstream = freezed, + Object? ahead = freezed, + Object? behind = freezed, + }) { + return _then(_$GitBranchImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + isCurrent: null == isCurrent + ? _value.isCurrent + : isCurrent // ignore: cast_nullable_to_non_nullable + as bool, + upstream: freezed == upstream + ? _value.upstream + : upstream // ignore: cast_nullable_to_non_nullable + as String?, + ahead: freezed == ahead + ? _value.ahead + : ahead // ignore: cast_nullable_to_non_nullable + as int?, + behind: freezed == behind + ? _value.behind + : behind // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$GitBranchImpl implements _GitBranch { + const _$GitBranchImpl( + {required this.name, + required this.isCurrent, + this.upstream, + this.ahead, + this.behind}); + + factory _$GitBranchImpl.fromJson(Map json) => + _$$GitBranchImplFromJson(json); + + @override + final String name; + @override + final bool isCurrent; + @override + final String? upstream; + @override + final int? ahead; + @override + final int? behind; + + @override + String toString() { + return 'GitBranch(name: $name, isCurrent: $isCurrent, upstream: $upstream, ahead: $ahead, behind: $behind)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GitBranchImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.isCurrent, isCurrent) || + other.isCurrent == isCurrent) && + (identical(other.upstream, upstream) || + other.upstream == upstream) && + (identical(other.ahead, ahead) || other.ahead == ahead) && + (identical(other.behind, behind) || other.behind == behind)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, name, isCurrent, upstream, ahead, behind); + + /// Create a copy of GitBranch + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GitBranchImplCopyWith<_$GitBranchImpl> get copyWith => + __$$GitBranchImplCopyWithImpl<_$GitBranchImpl>(this, _$identity); + + @override + Map toJson() { + return _$$GitBranchImplToJson( + this, + ); + } +} + +abstract class _GitBranch implements GitBranch { + const factory _GitBranch( + {required final String name, + required final bool isCurrent, + final String? upstream, + final int? ahead, + final int? behind}) = _$GitBranchImpl; + + factory _GitBranch.fromJson(Map json) = + _$GitBranchImpl.fromJson; + + @override + String get name; + @override + bool get isCurrent; + @override + String? get upstream; + @override + int? get ahead; + @override + int? get behind; + + /// Create a copy of GitBranch + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GitBranchImplCopyWith<_$GitBranchImpl> get copyWith => + throw _privateConstructorUsedError; +} + +DiffFile _$DiffFileFromJson(Map json) { + return _DiffFile.fromJson(json); +} + +/// @nodoc +mixin _$DiffFile { + String get path => throw _privateConstructorUsedError; + String get oldPath => throw _privateConstructorUsedError; + String get newPath => throw _privateConstructorUsedError; + FileChangeStatus get status => throw _privateConstructorUsedError; + int get additions => throw _privateConstructorUsedError; + int get deletions => throw _privateConstructorUsedError; + List get hunks => throw _privateConstructorUsedError; + String? get oldMode => throw _privateConstructorUsedError; + String? get newMode => throw _privateConstructorUsedError; + + /// Serializes this DiffFile to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of DiffFile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $DiffFileCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DiffFileCopyWith<$Res> { + factory $DiffFileCopyWith(DiffFile value, $Res Function(DiffFile) then) = + _$DiffFileCopyWithImpl<$Res, DiffFile>; + @useResult + $Res call( + {String path, + String oldPath, + String newPath, + FileChangeStatus status, + int additions, + int deletions, + List hunks, + String? oldMode, + String? newMode}); +} + +/// @nodoc +class _$DiffFileCopyWithImpl<$Res, $Val extends DiffFile> + implements $DiffFileCopyWith<$Res> { + _$DiffFileCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of DiffFile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? oldPath = null, + Object? newPath = null, + Object? status = null, + Object? additions = null, + Object? deletions = null, + Object? hunks = null, + Object? oldMode = freezed, + Object? newMode = freezed, + }) { + return _then(_value.copyWith( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + oldPath: null == oldPath + ? _value.oldPath + : oldPath // ignore: cast_nullable_to_non_nullable + as String, + newPath: null == newPath + ? _value.newPath + : newPath // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as FileChangeStatus, + additions: null == additions + ? _value.additions + : additions // ignore: cast_nullable_to_non_nullable + as int, + deletions: null == deletions + ? _value.deletions + : deletions // ignore: cast_nullable_to_non_nullable + as int, + hunks: null == hunks + ? _value.hunks + : hunks // ignore: cast_nullable_to_non_nullable + as List, + oldMode: freezed == oldMode + ? _value.oldMode + : oldMode // ignore: cast_nullable_to_non_nullable + as String?, + newMode: freezed == newMode + ? _value.newMode + : newMode // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$DiffFileImplCopyWith<$Res> + implements $DiffFileCopyWith<$Res> { + factory _$$DiffFileImplCopyWith( + _$DiffFileImpl value, $Res Function(_$DiffFileImpl) then) = + __$$DiffFileImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String path, + String oldPath, + String newPath, + FileChangeStatus status, + int additions, + int deletions, + List hunks, + String? oldMode, + String? newMode}); +} + +/// @nodoc +class __$$DiffFileImplCopyWithImpl<$Res> + extends _$DiffFileCopyWithImpl<$Res, _$DiffFileImpl> + implements _$$DiffFileImplCopyWith<$Res> { + __$$DiffFileImplCopyWithImpl( + _$DiffFileImpl _value, $Res Function(_$DiffFileImpl) _then) + : super(_value, _then); + + /// Create a copy of DiffFile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? path = null, + Object? oldPath = null, + Object? newPath = null, + Object? status = null, + Object? additions = null, + Object? deletions = null, + Object? hunks = null, + Object? oldMode = freezed, + Object? newMode = freezed, + }) { + return _then(_$DiffFileImpl( + path: null == path + ? _value.path + : path // ignore: cast_nullable_to_non_nullable + as String, + oldPath: null == oldPath + ? _value.oldPath + : oldPath // ignore: cast_nullable_to_non_nullable + as String, + newPath: null == newPath + ? _value.newPath + : newPath // ignore: cast_nullable_to_non_nullable + as String, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as FileChangeStatus, + additions: null == additions + ? _value.additions + : additions // ignore: cast_nullable_to_non_nullable + as int, + deletions: null == deletions + ? _value.deletions + : deletions // ignore: cast_nullable_to_non_nullable + as int, + hunks: null == hunks + ? _value._hunks + : hunks // ignore: cast_nullable_to_non_nullable + as List, + oldMode: freezed == oldMode + ? _value.oldMode + : oldMode // ignore: cast_nullable_to_non_nullable + as String?, + newMode: freezed == newMode + ? _value.newMode + : newMode // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$DiffFileImpl implements _DiffFile { + const _$DiffFileImpl( + {required this.path, + required this.oldPath, + required this.newPath, + required this.status, + required this.additions, + required this.deletions, + required final List hunks, + this.oldMode, + this.newMode}) + : _hunks = hunks; + + factory _$DiffFileImpl.fromJson(Map json) => + _$$DiffFileImplFromJson(json); + + @override + final String path; + @override + final String oldPath; + @override + final String newPath; + @override + final FileChangeStatus status; + @override + final int additions; + @override + final int deletions; + final List _hunks; + @override + List get hunks { + if (_hunks is EqualUnmodifiableListView) return _hunks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_hunks); + } + + @override + final String? oldMode; + @override + final String? newMode; + + @override + String toString() { + return 'DiffFile(path: $path, oldPath: $oldPath, newPath: $newPath, status: $status, additions: $additions, deletions: $deletions, hunks: $hunks, oldMode: $oldMode, newMode: $newMode)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$DiffFileImpl && + (identical(other.path, path) || other.path == path) && + (identical(other.oldPath, oldPath) || other.oldPath == oldPath) && + (identical(other.newPath, newPath) || other.newPath == newPath) && + (identical(other.status, status) || other.status == status) && + (identical(other.additions, additions) || + other.additions == additions) && + (identical(other.deletions, deletions) || + other.deletions == deletions) && + const DeepCollectionEquality().equals(other._hunks, _hunks) && + (identical(other.oldMode, oldMode) || other.oldMode == oldMode) && + (identical(other.newMode, newMode) || other.newMode == newMode)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + path, + oldPath, + newPath, + status, + additions, + deletions, + const DeepCollectionEquality().hash(_hunks), + oldMode, + newMode); + + /// Create a copy of DiffFile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$DiffFileImplCopyWith<_$DiffFileImpl> get copyWith => + __$$DiffFileImplCopyWithImpl<_$DiffFileImpl>(this, _$identity); + + @override + Map toJson() { + return _$$DiffFileImplToJson( + this, + ); + } +} + +abstract class _DiffFile implements DiffFile { + const factory _DiffFile( + {required final String path, + required final String oldPath, + required final String newPath, + required final FileChangeStatus status, + required final int additions, + required final int deletions, + required final List hunks, + final String? oldMode, + final String? newMode}) = _$DiffFileImpl; + + factory _DiffFile.fromJson(Map json) = + _$DiffFileImpl.fromJson; + + @override + String get path; + @override + String get oldPath; + @override + String get newPath; + @override + FileChangeStatus get status; + @override + int get additions; + @override + int get deletions; + @override + List get hunks; + @override + String? get oldMode; + @override + String? get newMode; + + /// Create a copy of DiffFile + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$DiffFileImplCopyWith<_$DiffFileImpl> get copyWith => + throw _privateConstructorUsedError; +} + +DiffHunk _$DiffHunkFromJson(Map json) { + return _DiffHunk.fromJson(json); +} + +/// @nodoc +mixin _$DiffHunk { + String get header => throw _privateConstructorUsedError; + int get oldStart => throw _privateConstructorUsedError; + int get oldLines => throw _privateConstructorUsedError; + int get newStart => throw _privateConstructorUsedError; + int get newLines => throw _privateConstructorUsedError; + List get lines => throw _privateConstructorUsedError; + + /// Serializes this DiffHunk to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of DiffHunk + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $DiffHunkCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DiffHunkCopyWith<$Res> { + factory $DiffHunkCopyWith(DiffHunk value, $Res Function(DiffHunk) then) = + _$DiffHunkCopyWithImpl<$Res, DiffHunk>; + @useResult + $Res call( + {String header, + int oldStart, + int oldLines, + int newStart, + int newLines, + List lines}); +} + +/// @nodoc +class _$DiffHunkCopyWithImpl<$Res, $Val extends DiffHunk> + implements $DiffHunkCopyWith<$Res> { + _$DiffHunkCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of DiffHunk + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? header = null, + Object? oldStart = null, + Object? oldLines = null, + Object? newStart = null, + Object? newLines = null, + Object? lines = null, + }) { + return _then(_value.copyWith( + header: null == header + ? _value.header + : header // ignore: cast_nullable_to_non_nullable + as String, + oldStart: null == oldStart + ? _value.oldStart + : oldStart // ignore: cast_nullable_to_non_nullable + as int, + oldLines: null == oldLines + ? _value.oldLines + : oldLines // ignore: cast_nullable_to_non_nullable + as int, + newStart: null == newStart + ? _value.newStart + : newStart // ignore: cast_nullable_to_non_nullable + as int, + newLines: null == newLines + ? _value.newLines + : newLines // ignore: cast_nullable_to_non_nullable + as int, + lines: null == lines + ? _value.lines + : lines // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$DiffHunkImplCopyWith<$Res> + implements $DiffHunkCopyWith<$Res> { + factory _$$DiffHunkImplCopyWith( + _$DiffHunkImpl value, $Res Function(_$DiffHunkImpl) then) = + __$$DiffHunkImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String header, + int oldStart, + int oldLines, + int newStart, + int newLines, + List lines}); +} + +/// @nodoc +class __$$DiffHunkImplCopyWithImpl<$Res> + extends _$DiffHunkCopyWithImpl<$Res, _$DiffHunkImpl> + implements _$$DiffHunkImplCopyWith<$Res> { + __$$DiffHunkImplCopyWithImpl( + _$DiffHunkImpl _value, $Res Function(_$DiffHunkImpl) _then) + : super(_value, _then); + + /// Create a copy of DiffHunk + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? header = null, + Object? oldStart = null, + Object? oldLines = null, + Object? newStart = null, + Object? newLines = null, + Object? lines = null, + }) { + return _then(_$DiffHunkImpl( + header: null == header + ? _value.header + : header // ignore: cast_nullable_to_non_nullable + as String, + oldStart: null == oldStart + ? _value.oldStart + : oldStart // ignore: cast_nullable_to_non_nullable + as int, + oldLines: null == oldLines + ? _value.oldLines + : oldLines // ignore: cast_nullable_to_non_nullable + as int, + newStart: null == newStart + ? _value.newStart + : newStart // ignore: cast_nullable_to_non_nullable + as int, + newLines: null == newLines + ? _value.newLines + : newLines // ignore: cast_nullable_to_non_nullable + as int, + lines: null == lines + ? _value._lines + : lines // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$DiffHunkImpl implements _DiffHunk { + const _$DiffHunkImpl( + {required this.header, + required this.oldStart, + required this.oldLines, + required this.newStart, + required this.newLines, + required final List lines}) + : _lines = lines; + + factory _$DiffHunkImpl.fromJson(Map json) => + _$$DiffHunkImplFromJson(json); + + @override + final String header; + @override + final int oldStart; + @override + final int oldLines; + @override + final int newStart; + @override + final int newLines; + final List _lines; + @override + List get lines { + if (_lines is EqualUnmodifiableListView) return _lines; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_lines); + } + + @override + String toString() { + return 'DiffHunk(header: $header, oldStart: $oldStart, oldLines: $oldLines, newStart: $newStart, newLines: $newLines, lines: $lines)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$DiffHunkImpl && + (identical(other.header, header) || other.header == header) && + (identical(other.oldStart, oldStart) || + other.oldStart == oldStart) && + (identical(other.oldLines, oldLines) || + other.oldLines == oldLines) && + (identical(other.newStart, newStart) || + other.newStart == newStart) && + (identical(other.newLines, newLines) || + other.newLines == newLines) && + const DeepCollectionEquality().equals(other._lines, _lines)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, header, oldStart, oldLines, + newStart, newLines, const DeepCollectionEquality().hash(_lines)); + + /// Create a copy of DiffHunk + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$DiffHunkImplCopyWith<_$DiffHunkImpl> get copyWith => + __$$DiffHunkImplCopyWithImpl<_$DiffHunkImpl>(this, _$identity); + + @override + Map toJson() { + return _$$DiffHunkImplToJson( + this, + ); + } +} + +abstract class _DiffHunk implements DiffHunk { + const factory _DiffHunk( + {required final String header, + required final int oldStart, + required final int oldLines, + required final int newStart, + required final int newLines, + required final List lines}) = _$DiffHunkImpl; + + factory _DiffHunk.fromJson(Map json) = + _$DiffHunkImpl.fromJson; + + @override + String get header; + @override + int get oldStart; + @override + int get oldLines; + @override + int get newStart; + @override + int get newLines; + @override + List get lines; + + /// Create a copy of DiffHunk + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$DiffHunkImplCopyWith<_$DiffHunkImpl> get copyWith => + throw _privateConstructorUsedError; +} + +DiffLine _$DiffLineFromJson(Map json) { + return _DiffLine.fromJson(json); +} + +/// @nodoc +mixin _$DiffLine { + DiffLineType get type => throw _privateConstructorUsedError; + String get content => throw _privateConstructorUsedError; + int? get oldLineNumber => throw _privateConstructorUsedError; + int? get newLineNumber => throw _privateConstructorUsedError; + + /// Serializes this DiffLine to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of DiffLine + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $DiffLineCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DiffLineCopyWith<$Res> { + factory $DiffLineCopyWith(DiffLine value, $Res Function(DiffLine) then) = + _$DiffLineCopyWithImpl<$Res, DiffLine>; + @useResult + $Res call( + {DiffLineType type, + String content, + int? oldLineNumber, + int? newLineNumber}); +} + +/// @nodoc +class _$DiffLineCopyWithImpl<$Res, $Val extends DiffLine> + implements $DiffLineCopyWith<$Res> { + _$DiffLineCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of DiffLine + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? content = null, + Object? oldLineNumber = freezed, + Object? newLineNumber = freezed, + }) { + return _then(_value.copyWith( + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as DiffLineType, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + oldLineNumber: freezed == oldLineNumber + ? _value.oldLineNumber + : oldLineNumber // ignore: cast_nullable_to_non_nullable + as int?, + newLineNumber: freezed == newLineNumber + ? _value.newLineNumber + : newLineNumber // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$DiffLineImplCopyWith<$Res> + implements $DiffLineCopyWith<$Res> { + factory _$$DiffLineImplCopyWith( + _$DiffLineImpl value, $Res Function(_$DiffLineImpl) then) = + __$$DiffLineImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {DiffLineType type, + String content, + int? oldLineNumber, + int? newLineNumber}); +} + +/// @nodoc +class __$$DiffLineImplCopyWithImpl<$Res> + extends _$DiffLineCopyWithImpl<$Res, _$DiffLineImpl> + implements _$$DiffLineImplCopyWith<$Res> { + __$$DiffLineImplCopyWithImpl( + _$DiffLineImpl _value, $Res Function(_$DiffLineImpl) _then) + : super(_value, _then); + + /// Create a copy of DiffLine + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? content = null, + Object? oldLineNumber = freezed, + Object? newLineNumber = freezed, + }) { + return _then(_$DiffLineImpl( + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as DiffLineType, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + oldLineNumber: freezed == oldLineNumber + ? _value.oldLineNumber + : oldLineNumber // ignore: cast_nullable_to_non_nullable + as int?, + newLineNumber: freezed == newLineNumber + ? _value.newLineNumber + : newLineNumber // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$DiffLineImpl implements _DiffLine { + const _$DiffLineImpl( + {required this.type, + required this.content, + this.oldLineNumber, + this.newLineNumber}); + + factory _$DiffLineImpl.fromJson(Map json) => + _$$DiffLineImplFromJson(json); + + @override + final DiffLineType type; + @override + final String content; + @override + final int? oldLineNumber; + @override + final int? newLineNumber; + + @override + String toString() { + return 'DiffLine(type: $type, content: $content, oldLineNumber: $oldLineNumber, newLineNumber: $newLineNumber)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$DiffLineImpl && + (identical(other.type, type) || other.type == type) && + (identical(other.content, content) || other.content == content) && + (identical(other.oldLineNumber, oldLineNumber) || + other.oldLineNumber == oldLineNumber) && + (identical(other.newLineNumber, newLineNumber) || + other.newLineNumber == newLineNumber)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => + Object.hash(runtimeType, type, content, oldLineNumber, newLineNumber); + + /// Create a copy of DiffLine + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$DiffLineImplCopyWith<_$DiffLineImpl> get copyWith => + __$$DiffLineImplCopyWithImpl<_$DiffLineImpl>(this, _$identity); + + @override + Map toJson() { + return _$$DiffLineImplToJson( + this, + ); + } +} + +abstract class _DiffLine implements DiffLine { + const factory _DiffLine( + {required final DiffLineType type, + required final String content, + final int? oldLineNumber, + final int? newLineNumber}) = _$DiffLineImpl; + + factory _DiffLine.fromJson(Map json) = + _$DiffLineImpl.fromJson; + + @override + DiffLineType get type; + @override + String get content; + @override + int? get oldLineNumber; + @override + int? get newLineNumber; + + /// Create a copy of DiffLine + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$DiffLineImplCopyWith<_$DiffLineImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/git_models.g.dart b/apps/mobile/lib/core/models/git_models.g.dart new file mode 100644 index 0000000..5c38070 --- /dev/null +++ b/apps/mobile/lib/core/models/git_models.g.dart @@ -0,0 +1,143 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'git_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$GitStatusImpl _$$GitStatusImplFromJson(Map json) => + _$GitStatusImpl( + branch: json['branch'] as String, + changes: (json['changes'] as List) + .map((e) => GitFileChange.fromJson(e as Map)) + .toList(), + ahead: (json['ahead'] as num).toInt(), + behind: (json['behind'] as num).toInt(), + isClean: json['isClean'] as bool, + ); + +Map _$$GitStatusImplToJson(_$GitStatusImpl instance) => + { + 'branch': instance.branch, + 'changes': instance.changes, + 'ahead': instance.ahead, + 'behind': instance.behind, + 'isClean': instance.isClean, + }; + +_$GitFileChangeImpl _$$GitFileChangeImplFromJson(Map json) => + _$GitFileChangeImpl( + path: json['path'] as String, + status: $enumDecode(_$FileChangeStatusEnumMap, json['status']), + additions: (json['additions'] as num?)?.toInt(), + deletions: (json['deletions'] as num?)?.toInt(), + diff: json['diff'] as String?, + ); + +Map _$$GitFileChangeImplToJson(_$GitFileChangeImpl instance) => + { + 'path': instance.path, + 'status': _$FileChangeStatusEnumMap[instance.status]!, + 'additions': instance.additions, + 'deletions': instance.deletions, + 'diff': instance.diff, + }; + +const _$FileChangeStatusEnumMap = { + FileChangeStatus.modified: 'modified', + FileChangeStatus.added: 'added', + FileChangeStatus.deleted: 'deleted', + FileChangeStatus.untracked: 'untracked', + FileChangeStatus.renamed: 'renamed', +}; + +_$GitBranchImpl _$$GitBranchImplFromJson(Map json) => + _$GitBranchImpl( + name: json['name'] as String, + isCurrent: json['isCurrent'] as bool, + upstream: json['upstream'] as String?, + ahead: (json['ahead'] as num?)?.toInt(), + behind: (json['behind'] as num?)?.toInt(), + ); + +Map _$$GitBranchImplToJson(_$GitBranchImpl instance) => + { + 'name': instance.name, + 'isCurrent': instance.isCurrent, + 'upstream': instance.upstream, + 'ahead': instance.ahead, + 'behind': instance.behind, + }; + +_$DiffFileImpl _$$DiffFileImplFromJson(Map json) => + _$DiffFileImpl( + path: json['path'] as String, + oldPath: json['oldPath'] as String, + newPath: json['newPath'] as String, + status: $enumDecode(_$FileChangeStatusEnumMap, json['status']), + additions: (json['additions'] as num).toInt(), + deletions: (json['deletions'] as num).toInt(), + hunks: (json['hunks'] as List) + .map((e) => DiffHunk.fromJson(e as Map)) + .toList(), + oldMode: json['oldMode'] as String?, + newMode: json['newMode'] as String?, + ); + +Map _$$DiffFileImplToJson(_$DiffFileImpl instance) => + { + 'path': instance.path, + 'oldPath': instance.oldPath, + 'newPath': instance.newPath, + 'status': _$FileChangeStatusEnumMap[instance.status]!, + 'additions': instance.additions, + 'deletions': instance.deletions, + 'hunks': instance.hunks, + 'oldMode': instance.oldMode, + 'newMode': instance.newMode, + }; + +_$DiffHunkImpl _$$DiffHunkImplFromJson(Map json) => + _$DiffHunkImpl( + header: json['header'] as String, + oldStart: (json['oldStart'] as num).toInt(), + oldLines: (json['oldLines'] as num).toInt(), + newStart: (json['newStart'] as num).toInt(), + newLines: (json['newLines'] as num).toInt(), + lines: (json['lines'] as List) + .map((e) => DiffLine.fromJson(e as Map)) + .toList(), + ); + +Map _$$DiffHunkImplToJson(_$DiffHunkImpl instance) => + { + 'header': instance.header, + 'oldStart': instance.oldStart, + 'oldLines': instance.oldLines, + 'newStart': instance.newStart, + 'newLines': instance.newLines, + 'lines': instance.lines, + }; + +_$DiffLineImpl _$$DiffLineImplFromJson(Map json) => + _$DiffLineImpl( + type: $enumDecode(_$DiffLineTypeEnumMap, json['type']), + content: json['content'] as String, + oldLineNumber: (json['oldLineNumber'] as num?)?.toInt(), + newLineNumber: (json['newLineNumber'] as num?)?.toInt(), + ); + +Map _$$DiffLineImplToJson(_$DiffLineImpl instance) => + { + 'type': _$DiffLineTypeEnumMap[instance.type]!, + 'content': instance.content, + 'oldLineNumber': instance.oldLineNumber, + 'newLineNumber': instance.newLineNumber, + }; + +const _$DiffLineTypeEnumMap = { + DiffLineType.context: 'context', + DiffLineType.added: 'added', + DiffLineType.removed: 'removed', +}; diff --git a/apps/mobile/lib/core/models/hook_models.dart b/apps/mobile/lib/core/models/hook_models.dart new file mode 100644 index 0000000..ca80534 --- /dev/null +++ b/apps/mobile/lib/core/models/hook_models.dart @@ -0,0 +1,29 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'hook_models.freezed.dart'; +part 'hook_models.g.dart'; + +enum HookEventType { + sessionStart, + sessionEnd, + preToolUse, + postToolUse, + userPromptSubmit, + stop, + subagentStop, + preCompact, + notification, +} + +@freezed +class HookEvent with _$HookEvent { + const factory HookEvent({ + required String eventType, + required String sessionId, + required DateTime timestamp, + required Map payload, + }) = _HookEvent; + + factory HookEvent.fromJson(Map json) => + _$HookEventFromJson(json); +} diff --git a/apps/mobile/lib/core/models/hook_models.freezed.dart b/apps/mobile/lib/core/models/hook_models.freezed.dart new file mode 100644 index 0000000..73935f2 --- /dev/null +++ b/apps/mobile/lib/core/models/hook_models.freezed.dart @@ -0,0 +1,237 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'hook_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +HookEvent _$HookEventFromJson(Map json) { + return _HookEvent.fromJson(json); +} + +/// @nodoc +mixin _$HookEvent { + String get eventType => throw _privateConstructorUsedError; + String get sessionId => throw _privateConstructorUsedError; + DateTime get timestamp => throw _privateConstructorUsedError; + Map get payload => throw _privateConstructorUsedError; + + /// Serializes this HookEvent to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of HookEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $HookEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HookEventCopyWith<$Res> { + factory $HookEventCopyWith(HookEvent value, $Res Function(HookEvent) then) = + _$HookEventCopyWithImpl<$Res, HookEvent>; + @useResult + $Res call( + {String eventType, + String sessionId, + DateTime timestamp, + Map payload}); +} + +/// @nodoc +class _$HookEventCopyWithImpl<$Res, $Val extends HookEvent> + implements $HookEventCopyWith<$Res> { + _$HookEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of HookEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? eventType = null, + Object? sessionId = null, + Object? timestamp = null, + Object? payload = null, + }) { + return _then(_value.copyWith( + eventType: null == eventType + ? _value.eventType + : eventType // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + payload: null == payload + ? _value.payload + : payload // ignore: cast_nullable_to_non_nullable + as Map, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$HookEventImplCopyWith<$Res> + implements $HookEventCopyWith<$Res> { + factory _$$HookEventImplCopyWith( + _$HookEventImpl value, $Res Function(_$HookEventImpl) then) = + __$$HookEventImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String eventType, + String sessionId, + DateTime timestamp, + Map payload}); +} + +/// @nodoc +class __$$HookEventImplCopyWithImpl<$Res> + extends _$HookEventCopyWithImpl<$Res, _$HookEventImpl> + implements _$$HookEventImplCopyWith<$Res> { + __$$HookEventImplCopyWithImpl( + _$HookEventImpl _value, $Res Function(_$HookEventImpl) _then) + : super(_value, _then); + + /// Create a copy of HookEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? eventType = null, + Object? sessionId = null, + Object? timestamp = null, + Object? payload = null, + }) { + return _then(_$HookEventImpl( + eventType: null == eventType + ? _value.eventType + : eventType // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + payload: null == payload + ? _value._payload + : payload // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$HookEventImpl implements _HookEvent { + const _$HookEventImpl( + {required this.eventType, + required this.sessionId, + required this.timestamp, + required final Map payload}) + : _payload = payload; + + factory _$HookEventImpl.fromJson(Map json) => + _$$HookEventImplFromJson(json); + + @override + final String eventType; + @override + final String sessionId; + @override + final DateTime timestamp; + final Map _payload; + @override + Map get payload { + if (_payload is EqualUnmodifiableMapView) return _payload; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_payload); + } + + @override + String toString() { + return 'HookEvent(eventType: $eventType, sessionId: $sessionId, timestamp: $timestamp, payload: $payload)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$HookEventImpl && + (identical(other.eventType, eventType) || + other.eventType == eventType) && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId) && + (identical(other.timestamp, timestamp) || + other.timestamp == timestamp) && + const DeepCollectionEquality().equals(other._payload, _payload)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, eventType, sessionId, timestamp, + const DeepCollectionEquality().hash(_payload)); + + /// Create a copy of HookEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$HookEventImplCopyWith<_$HookEventImpl> get copyWith => + __$$HookEventImplCopyWithImpl<_$HookEventImpl>(this, _$identity); + + @override + Map toJson() { + return _$$HookEventImplToJson( + this, + ); + } +} + +abstract class _HookEvent implements HookEvent { + const factory _HookEvent( + {required final String eventType, + required final String sessionId, + required final DateTime timestamp, + required final Map payload}) = _$HookEventImpl; + + factory _HookEvent.fromJson(Map json) = + _$HookEventImpl.fromJson; + + @override + String get eventType; + @override + String get sessionId; + @override + DateTime get timestamp; + @override + Map get payload; + + /// Create a copy of HookEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$HookEventImplCopyWith<_$HookEventImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/hook_models.g.dart b/apps/mobile/lib/core/models/hook_models.g.dart new file mode 100644 index 0000000..142077b --- /dev/null +++ b/apps/mobile/lib/core/models/hook_models.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hook_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$HookEventImpl _$$HookEventImplFromJson(Map json) => + _$HookEventImpl( + eventType: json['eventType'] as String, + sessionId: json['sessionId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + payload: json['payload'] as Map, + ); + +Map _$$HookEventImplToJson(_$HookEventImpl instance) => + { + 'eventType': instance.eventType, + 'sessionId': instance.sessionId, + 'timestamp': instance.timestamp.toIso8601String(), + 'payload': instance.payload, + }; diff --git a/apps/mobile/lib/core/models/message_models.dart b/apps/mobile/lib/core/models/message_models.dart new file mode 100644 index 0000000..45c9822 --- /dev/null +++ b/apps/mobile/lib/core/models/message_models.dart @@ -0,0 +1,91 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'message_models.freezed.dart'; +part 'message_models.g.dart'; + +enum MessageRole { user, agent, system } + +enum MessageType { text, toolCall, toolResult, system } + +@freezed +class MessagePart with _$MessagePart { + const factory MessagePart.text({ + required String content, + }) = TextPart; + + const factory MessagePart.toolUse({ + required String tool, + required Map params, + String? id, + }) = ToolUsePart; + + const factory MessagePart.toolResult({ + required String toolCallId, + required ToolResult result, + }) = ToolResultPart; + + const factory MessagePart.thinking({ + required String content, + }) = ThinkingPart; + + factory MessagePart.fromJson(Map json) => + _$MessagePartFromJson(json); +} + +@freezed +class ToolResult with _$ToolResult { + const factory ToolResult({ + required bool success, + required String content, + Map? metadata, + String? error, + int? durationMs, + }) = _ToolResult; + + factory ToolResult.fromJson(Map json) => + _$ToolResultFromJson(json); +} + +enum RiskLevel { low, medium, high, critical } + +enum ApprovalDecision { pending, approved, rejected, modified } + +@freezed +class ToolCall with _$ToolCall { + const factory ToolCall({ + required String id, + required String sessionId, + required String tool, + required Map params, + String? description, + String? reasoning, + @Default(RiskLevel.low) RiskLevel riskLevel, + @Default(ApprovalDecision.pending) ApprovalDecision decision, + String? modifications, + Map? result, + required DateTime createdAt, + DateTime? decidedAt, + }) = _ToolCall; + + factory ToolCall.fromJson(Map json) => + _$ToolCallFromJson(json); +} + +@freezed +class Message with _$Message { + const factory Message({ + required String id, + required String sessionId, + required MessageRole role, + required String content, + required MessageType type, + required List parts, + Map? metadata, + required DateTime createdAt, + DateTime? updatedAt, + @Default(true) bool synced, + }) = _Message; + + factory Message.fromJson(Map json) => + _$MessageFromJson(json); +} diff --git a/apps/mobile/lib/core/models/message_models.freezed.dart b/apps/mobile/lib/core/models/message_models.freezed.dart new file mode 100644 index 0000000..771541f --- /dev/null +++ b/apps/mobile/lib/core/models/message_models.freezed.dart @@ -0,0 +1,1885 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'message_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +MessagePart _$MessagePartFromJson(Map json) { + switch (json['runtimeType']) { + case 'text': + return TextPart.fromJson(json); + case 'toolUse': + return ToolUsePart.fromJson(json); + case 'toolResult': + return ToolResultPart.fromJson(json); + case 'thinking': + return ThinkingPart.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'MessagePart', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$MessagePart { + @optionalTypeArgs + TResult when({ + required TResult Function(String content) text, + required TResult Function( + String tool, Map params, String? id) + toolUse, + required TResult Function(String toolCallId, ToolResult result) toolResult, + required TResult Function(String content) thinking, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String content)? text, + TResult? Function(String tool, Map params, String? id)? + toolUse, + TResult? Function(String toolCallId, ToolResult result)? toolResult, + TResult? Function(String content)? thinking, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String content)? text, + TResult Function(String tool, Map params, String? id)? + toolUse, + TResult Function(String toolCallId, ToolResult result)? toolResult, + TResult Function(String content)? thinking, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(TextPart value) text, + required TResult Function(ToolUsePart value) toolUse, + required TResult Function(ToolResultPart value) toolResult, + required TResult Function(ThinkingPart value) thinking, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextPart value)? text, + TResult? Function(ToolUsePart value)? toolUse, + TResult? Function(ToolResultPart value)? toolResult, + TResult? Function(ThinkingPart value)? thinking, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextPart value)? text, + TResult Function(ToolUsePart value)? toolUse, + TResult Function(ToolResultPart value)? toolResult, + TResult Function(ThinkingPart value)? thinking, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + /// Serializes this MessagePart to a JSON map. + Map toJson() => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MessagePartCopyWith<$Res> { + factory $MessagePartCopyWith( + MessagePart value, $Res Function(MessagePart) then) = + _$MessagePartCopyWithImpl<$Res, MessagePart>; +} + +/// @nodoc +class _$MessagePartCopyWithImpl<$Res, $Val extends MessagePart> + implements $MessagePartCopyWith<$Res> { + _$MessagePartCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$TextPartImplCopyWith<$Res> { + factory _$$TextPartImplCopyWith( + _$TextPartImpl value, $Res Function(_$TextPartImpl) then) = + __$$TextPartImplCopyWithImpl<$Res>; + @useResult + $Res call({String content}); +} + +/// @nodoc +class __$$TextPartImplCopyWithImpl<$Res> + extends _$MessagePartCopyWithImpl<$Res, _$TextPartImpl> + implements _$$TextPartImplCopyWith<$Res> { + __$$TextPartImplCopyWithImpl( + _$TextPartImpl _value, $Res Function(_$TextPartImpl) _then) + : super(_value, _then); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? content = null, + }) { + return _then(_$TextPartImpl( + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$TextPartImpl implements TextPart { + const _$TextPartImpl({required this.content, final String? $type}) + : $type = $type ?? 'text'; + + factory _$TextPartImpl.fromJson(Map json) => + _$$TextPartImplFromJson(json); + + @override + final String content; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'MessagePart.text(content: $content)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$TextPartImpl && + (identical(other.content, content) || other.content == content)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, content); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$TextPartImplCopyWith<_$TextPartImpl> get copyWith => + __$$TextPartImplCopyWithImpl<_$TextPartImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String content) text, + required TResult Function( + String tool, Map params, String? id) + toolUse, + required TResult Function(String toolCallId, ToolResult result) toolResult, + required TResult Function(String content) thinking, + }) { + return text(content); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String content)? text, + TResult? Function(String tool, Map params, String? id)? + toolUse, + TResult? Function(String toolCallId, ToolResult result)? toolResult, + TResult? Function(String content)? thinking, + }) { + return text?.call(content); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String content)? text, + TResult Function(String tool, Map params, String? id)? + toolUse, + TResult Function(String toolCallId, ToolResult result)? toolResult, + TResult Function(String content)? thinking, + required TResult orElse(), + }) { + if (text != null) { + return text(content); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TextPart value) text, + required TResult Function(ToolUsePart value) toolUse, + required TResult Function(ToolResultPart value) toolResult, + required TResult Function(ThinkingPart value) thinking, + }) { + return text(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextPart value)? text, + TResult? Function(ToolUsePart value)? toolUse, + TResult? Function(ToolResultPart value)? toolResult, + TResult? Function(ThinkingPart value)? thinking, + }) { + return text?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextPart value)? text, + TResult Function(ToolUsePart value)? toolUse, + TResult Function(ToolResultPart value)? toolResult, + TResult Function(ThinkingPart value)? thinking, + required TResult orElse(), + }) { + if (text != null) { + return text(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$TextPartImplToJson( + this, + ); + } +} + +abstract class TextPart implements MessagePart { + const factory TextPart({required final String content}) = _$TextPartImpl; + + factory TextPart.fromJson(Map json) = + _$TextPartImpl.fromJson; + + String get content; + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$TextPartImplCopyWith<_$TextPartImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ToolUsePartImplCopyWith<$Res> { + factory _$$ToolUsePartImplCopyWith( + _$ToolUsePartImpl value, $Res Function(_$ToolUsePartImpl) then) = + __$$ToolUsePartImplCopyWithImpl<$Res>; + @useResult + $Res call({String tool, Map params, String? id}); +} + +/// @nodoc +class __$$ToolUsePartImplCopyWithImpl<$Res> + extends _$MessagePartCopyWithImpl<$Res, _$ToolUsePartImpl> + implements _$$ToolUsePartImplCopyWith<$Res> { + __$$ToolUsePartImplCopyWithImpl( + _$ToolUsePartImpl _value, $Res Function(_$ToolUsePartImpl) _then) + : super(_value, _then); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tool = null, + Object? params = null, + Object? id = freezed, + }) { + return _then(_$ToolUsePartImpl( + tool: null == tool + ? _value.tool + : tool // ignore: cast_nullable_to_non_nullable + as String, + params: null == params + ? _value._params + : params // ignore: cast_nullable_to_non_nullable + as Map, + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ToolUsePartImpl implements ToolUsePart { + const _$ToolUsePartImpl( + {required this.tool, + required final Map params, + this.id, + final String? $type}) + : _params = params, + $type = $type ?? 'toolUse'; + + factory _$ToolUsePartImpl.fromJson(Map json) => + _$$ToolUsePartImplFromJson(json); + + @override + final String tool; + final Map _params; + @override + Map get params { + if (_params is EqualUnmodifiableMapView) return _params; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_params); + } + + @override + final String? id; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'MessagePart.toolUse(tool: $tool, params: $params, id: $id)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ToolUsePartImpl && + (identical(other.tool, tool) || other.tool == tool) && + const DeepCollectionEquality().equals(other._params, _params) && + (identical(other.id, id) || other.id == id)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, tool, const DeepCollectionEquality().hash(_params), id); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ToolUsePartImplCopyWith<_$ToolUsePartImpl> get copyWith => + __$$ToolUsePartImplCopyWithImpl<_$ToolUsePartImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String content) text, + required TResult Function( + String tool, Map params, String? id) + toolUse, + required TResult Function(String toolCallId, ToolResult result) toolResult, + required TResult Function(String content) thinking, + }) { + return toolUse(tool, params, id); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String content)? text, + TResult? Function(String tool, Map params, String? id)? + toolUse, + TResult? Function(String toolCallId, ToolResult result)? toolResult, + TResult? Function(String content)? thinking, + }) { + return toolUse?.call(tool, params, id); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String content)? text, + TResult Function(String tool, Map params, String? id)? + toolUse, + TResult Function(String toolCallId, ToolResult result)? toolResult, + TResult Function(String content)? thinking, + required TResult orElse(), + }) { + if (toolUse != null) { + return toolUse(tool, params, id); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TextPart value) text, + required TResult Function(ToolUsePart value) toolUse, + required TResult Function(ToolResultPart value) toolResult, + required TResult Function(ThinkingPart value) thinking, + }) { + return toolUse(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextPart value)? text, + TResult? Function(ToolUsePart value)? toolUse, + TResult? Function(ToolResultPart value)? toolResult, + TResult? Function(ThinkingPart value)? thinking, + }) { + return toolUse?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextPart value)? text, + TResult Function(ToolUsePart value)? toolUse, + TResult Function(ToolResultPart value)? toolResult, + TResult Function(ThinkingPart value)? thinking, + required TResult orElse(), + }) { + if (toolUse != null) { + return toolUse(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$ToolUsePartImplToJson( + this, + ); + } +} + +abstract class ToolUsePart implements MessagePart { + const factory ToolUsePart( + {required final String tool, + required final Map params, + final String? id}) = _$ToolUsePartImpl; + + factory ToolUsePart.fromJson(Map json) = + _$ToolUsePartImpl.fromJson; + + String get tool; + Map get params; + String? get id; + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ToolUsePartImplCopyWith<_$ToolUsePartImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ToolResultPartImplCopyWith<$Res> { + factory _$$ToolResultPartImplCopyWith(_$ToolResultPartImpl value, + $Res Function(_$ToolResultPartImpl) then) = + __$$ToolResultPartImplCopyWithImpl<$Res>; + @useResult + $Res call({String toolCallId, ToolResult result}); + + $ToolResultCopyWith<$Res> get result; +} + +/// @nodoc +class __$$ToolResultPartImplCopyWithImpl<$Res> + extends _$MessagePartCopyWithImpl<$Res, _$ToolResultPartImpl> + implements _$$ToolResultPartImplCopyWith<$Res> { + __$$ToolResultPartImplCopyWithImpl( + _$ToolResultPartImpl _value, $Res Function(_$ToolResultPartImpl) _then) + : super(_value, _then); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? toolCallId = null, + Object? result = null, + }) { + return _then(_$ToolResultPartImpl( + toolCallId: null == toolCallId + ? _value.toolCallId + : toolCallId // ignore: cast_nullable_to_non_nullable + as String, + result: null == result + ? _value.result + : result // ignore: cast_nullable_to_non_nullable + as ToolResult, + )); + } + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $ToolResultCopyWith<$Res> get result { + return $ToolResultCopyWith<$Res>(_value.result, (value) { + return _then(_value.copyWith(result: value)); + }); + } +} + +/// @nodoc +@JsonSerializable() +class _$ToolResultPartImpl implements ToolResultPart { + const _$ToolResultPartImpl( + {required this.toolCallId, required this.result, final String? $type}) + : $type = $type ?? 'toolResult'; + + factory _$ToolResultPartImpl.fromJson(Map json) => + _$$ToolResultPartImplFromJson(json); + + @override + final String toolCallId; + @override + final ToolResult result; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'MessagePart.toolResult(toolCallId: $toolCallId, result: $result)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ToolResultPartImpl && + (identical(other.toolCallId, toolCallId) || + other.toolCallId == toolCallId) && + (identical(other.result, result) || other.result == result)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, toolCallId, result); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ToolResultPartImplCopyWith<_$ToolResultPartImpl> get copyWith => + __$$ToolResultPartImplCopyWithImpl<_$ToolResultPartImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String content) text, + required TResult Function( + String tool, Map params, String? id) + toolUse, + required TResult Function(String toolCallId, ToolResult result) toolResult, + required TResult Function(String content) thinking, + }) { + return toolResult(toolCallId, result); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String content)? text, + TResult? Function(String tool, Map params, String? id)? + toolUse, + TResult? Function(String toolCallId, ToolResult result)? toolResult, + TResult? Function(String content)? thinking, + }) { + return toolResult?.call(toolCallId, result); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String content)? text, + TResult Function(String tool, Map params, String? id)? + toolUse, + TResult Function(String toolCallId, ToolResult result)? toolResult, + TResult Function(String content)? thinking, + required TResult orElse(), + }) { + if (toolResult != null) { + return toolResult(toolCallId, result); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TextPart value) text, + required TResult Function(ToolUsePart value) toolUse, + required TResult Function(ToolResultPart value) toolResult, + required TResult Function(ThinkingPart value) thinking, + }) { + return toolResult(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextPart value)? text, + TResult? Function(ToolUsePart value)? toolUse, + TResult? Function(ToolResultPart value)? toolResult, + TResult? Function(ThinkingPart value)? thinking, + }) { + return toolResult?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextPart value)? text, + TResult Function(ToolUsePart value)? toolUse, + TResult Function(ToolResultPart value)? toolResult, + TResult Function(ThinkingPart value)? thinking, + required TResult orElse(), + }) { + if (toolResult != null) { + return toolResult(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$ToolResultPartImplToJson( + this, + ); + } +} + +abstract class ToolResultPart implements MessagePart { + const factory ToolResultPart( + {required final String toolCallId, + required final ToolResult result}) = _$ToolResultPartImpl; + + factory ToolResultPart.fromJson(Map json) = + _$ToolResultPartImpl.fromJson; + + String get toolCallId; + ToolResult get result; + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ToolResultPartImplCopyWith<_$ToolResultPartImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ThinkingPartImplCopyWith<$Res> { + factory _$$ThinkingPartImplCopyWith( + _$ThinkingPartImpl value, $Res Function(_$ThinkingPartImpl) then) = + __$$ThinkingPartImplCopyWithImpl<$Res>; + @useResult + $Res call({String content}); +} + +/// @nodoc +class __$$ThinkingPartImplCopyWithImpl<$Res> + extends _$MessagePartCopyWithImpl<$Res, _$ThinkingPartImpl> + implements _$$ThinkingPartImplCopyWith<$Res> { + __$$ThinkingPartImplCopyWithImpl( + _$ThinkingPartImpl _value, $Res Function(_$ThinkingPartImpl) _then) + : super(_value, _then); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? content = null, + }) { + return _then(_$ThinkingPartImpl( + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ThinkingPartImpl implements ThinkingPart { + const _$ThinkingPartImpl({required this.content, final String? $type}) + : $type = $type ?? 'thinking'; + + factory _$ThinkingPartImpl.fromJson(Map json) => + _$$ThinkingPartImplFromJson(json); + + @override + final String content; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'MessagePart.thinking(content: $content)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ThinkingPartImpl && + (identical(other.content, content) || other.content == content)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, content); + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ThinkingPartImplCopyWith<_$ThinkingPartImpl> get copyWith => + __$$ThinkingPartImplCopyWithImpl<_$ThinkingPartImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String content) text, + required TResult Function( + String tool, Map params, String? id) + toolUse, + required TResult Function(String toolCallId, ToolResult result) toolResult, + required TResult Function(String content) thinking, + }) { + return thinking(content); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String content)? text, + TResult? Function(String tool, Map params, String? id)? + toolUse, + TResult? Function(String toolCallId, ToolResult result)? toolResult, + TResult? Function(String content)? thinking, + }) { + return thinking?.call(content); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String content)? text, + TResult Function(String tool, Map params, String? id)? + toolUse, + TResult Function(String toolCallId, ToolResult result)? toolResult, + TResult Function(String content)? thinking, + required TResult orElse(), + }) { + if (thinking != null) { + return thinking(content); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TextPart value) text, + required TResult Function(ToolUsePart value) toolUse, + required TResult Function(ToolResultPart value) toolResult, + required TResult Function(ThinkingPart value) thinking, + }) { + return thinking(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextPart value)? text, + TResult? Function(ToolUsePart value)? toolUse, + TResult? Function(ToolResultPart value)? toolResult, + TResult? Function(ThinkingPart value)? thinking, + }) { + return thinking?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextPart value)? text, + TResult Function(ToolUsePart value)? toolUse, + TResult Function(ToolResultPart value)? toolResult, + TResult Function(ThinkingPart value)? thinking, + required TResult orElse(), + }) { + if (thinking != null) { + return thinking(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$ThinkingPartImplToJson( + this, + ); + } +} + +abstract class ThinkingPart implements MessagePart { + const factory ThinkingPart({required final String content}) = + _$ThinkingPartImpl; + + factory ThinkingPart.fromJson(Map json) = + _$ThinkingPartImpl.fromJson; + + String get content; + + /// Create a copy of MessagePart + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ThinkingPartImplCopyWith<_$ThinkingPartImpl> get copyWith => + throw _privateConstructorUsedError; +} + +ToolResult _$ToolResultFromJson(Map json) { + return _ToolResult.fromJson(json); +} + +/// @nodoc +mixin _$ToolResult { + bool get success => throw _privateConstructorUsedError; + String get content => throw _privateConstructorUsedError; + Map? get metadata => throw _privateConstructorUsedError; + String? get error => throw _privateConstructorUsedError; + int? get durationMs => throw _privateConstructorUsedError; + + /// Serializes this ToolResult to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ToolResult + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ToolResultCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ToolResultCopyWith<$Res> { + factory $ToolResultCopyWith( + ToolResult value, $Res Function(ToolResult) then) = + _$ToolResultCopyWithImpl<$Res, ToolResult>; + @useResult + $Res call( + {bool success, + String content, + Map? metadata, + String? error, + int? durationMs}); +} + +/// @nodoc +class _$ToolResultCopyWithImpl<$Res, $Val extends ToolResult> + implements $ToolResultCopyWith<$Res> { + _$ToolResultCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ToolResult + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? success = null, + Object? content = null, + Object? metadata = freezed, + Object? error = freezed, + Object? durationMs = freezed, + }) { + return _then(_value.copyWith( + success: null == success + ? _value.success + : success // ignore: cast_nullable_to_non_nullable + as bool, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + metadata: freezed == metadata + ? _value.metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + error: freezed == error + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ToolResultImplCopyWith<$Res> + implements $ToolResultCopyWith<$Res> { + factory _$$ToolResultImplCopyWith( + _$ToolResultImpl value, $Res Function(_$ToolResultImpl) then) = + __$$ToolResultImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool success, + String content, + Map? metadata, + String? error, + int? durationMs}); +} + +/// @nodoc +class __$$ToolResultImplCopyWithImpl<$Res> + extends _$ToolResultCopyWithImpl<$Res, _$ToolResultImpl> + implements _$$ToolResultImplCopyWith<$Res> { + __$$ToolResultImplCopyWithImpl( + _$ToolResultImpl _value, $Res Function(_$ToolResultImpl) _then) + : super(_value, _then); + + /// Create a copy of ToolResult + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? success = null, + Object? content = null, + Object? metadata = freezed, + Object? error = freezed, + Object? durationMs = freezed, + }) { + return _then(_$ToolResultImpl( + success: null == success + ? _value.success + : success // ignore: cast_nullable_to_non_nullable + as bool, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + metadata: freezed == metadata + ? _value._metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + error: freezed == error + ? _value.error + : error // ignore: cast_nullable_to_non_nullable + as String?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ToolResultImpl implements _ToolResult { + const _$ToolResultImpl( + {required this.success, + required this.content, + final Map? metadata, + this.error, + this.durationMs}) + : _metadata = metadata; + + factory _$ToolResultImpl.fromJson(Map json) => + _$$ToolResultImplFromJson(json); + + @override + final bool success; + @override + final String content; + final Map? _metadata; + @override + Map? get metadata { + final value = _metadata; + if (value == null) return null; + if (_metadata is EqualUnmodifiableMapView) return _metadata; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + final String? error; + @override + final int? durationMs; + + @override + String toString() { + return 'ToolResult(success: $success, content: $content, metadata: $metadata, error: $error, durationMs: $durationMs)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ToolResultImpl && + (identical(other.success, success) || other.success == success) && + (identical(other.content, content) || other.content == content) && + const DeepCollectionEquality().equals(other._metadata, _metadata) && + (identical(other.error, error) || other.error == error) && + (identical(other.durationMs, durationMs) || + other.durationMs == durationMs)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, success, content, + const DeepCollectionEquality().hash(_metadata), error, durationMs); + + /// Create a copy of ToolResult + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ToolResultImplCopyWith<_$ToolResultImpl> get copyWith => + __$$ToolResultImplCopyWithImpl<_$ToolResultImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ToolResultImplToJson( + this, + ); + } +} + +abstract class _ToolResult implements ToolResult { + const factory _ToolResult( + {required final bool success, + required final String content, + final Map? metadata, + final String? error, + final int? durationMs}) = _$ToolResultImpl; + + factory _ToolResult.fromJson(Map json) = + _$ToolResultImpl.fromJson; + + @override + bool get success; + @override + String get content; + @override + Map? get metadata; + @override + String? get error; + @override + int? get durationMs; + + /// Create a copy of ToolResult + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ToolResultImplCopyWith<_$ToolResultImpl> get copyWith => + throw _privateConstructorUsedError; +} + +ToolCall _$ToolCallFromJson(Map json) { + return _ToolCall.fromJson(json); +} + +/// @nodoc +mixin _$ToolCall { + String get id => throw _privateConstructorUsedError; + String get sessionId => throw _privateConstructorUsedError; + String get tool => throw _privateConstructorUsedError; + Map get params => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + String? get reasoning => throw _privateConstructorUsedError; + RiskLevel get riskLevel => throw _privateConstructorUsedError; + ApprovalDecision get decision => throw _privateConstructorUsedError; + String? get modifications => throw _privateConstructorUsedError; + Map? get result => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime? get decidedAt => throw _privateConstructorUsedError; + + /// Serializes this ToolCall to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ToolCall + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ToolCallCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ToolCallCopyWith<$Res> { + factory $ToolCallCopyWith(ToolCall value, $Res Function(ToolCall) then) = + _$ToolCallCopyWithImpl<$Res, ToolCall>; + @useResult + $Res call( + {String id, + String sessionId, + String tool, + Map params, + String? description, + String? reasoning, + RiskLevel riskLevel, + ApprovalDecision decision, + String? modifications, + Map? result, + DateTime createdAt, + DateTime? decidedAt}); +} + +/// @nodoc +class _$ToolCallCopyWithImpl<$Res, $Val extends ToolCall> + implements $ToolCallCopyWith<$Res> { + _$ToolCallCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ToolCall + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? tool = null, + Object? params = null, + Object? description = freezed, + Object? reasoning = freezed, + Object? riskLevel = null, + Object? decision = null, + Object? modifications = freezed, + Object? result = freezed, + Object? createdAt = null, + Object? decidedAt = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + tool: null == tool + ? _value.tool + : tool // ignore: cast_nullable_to_non_nullable + as String, + params: null == params + ? _value.params + : params // ignore: cast_nullable_to_non_nullable + as Map, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + reasoning: freezed == reasoning + ? _value.reasoning + : reasoning // ignore: cast_nullable_to_non_nullable + as String?, + riskLevel: null == riskLevel + ? _value.riskLevel + : riskLevel // ignore: cast_nullable_to_non_nullable + as RiskLevel, + decision: null == decision + ? _value.decision + : decision // ignore: cast_nullable_to_non_nullable + as ApprovalDecision, + modifications: freezed == modifications + ? _value.modifications + : modifications // ignore: cast_nullable_to_non_nullable + as String?, + result: freezed == result + ? _value.result + : result // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + decidedAt: freezed == decidedAt + ? _value.decidedAt + : decidedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ToolCallImplCopyWith<$Res> + implements $ToolCallCopyWith<$Res> { + factory _$$ToolCallImplCopyWith( + _$ToolCallImpl value, $Res Function(_$ToolCallImpl) then) = + __$$ToolCallImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String sessionId, + String tool, + Map params, + String? description, + String? reasoning, + RiskLevel riskLevel, + ApprovalDecision decision, + String? modifications, + Map? result, + DateTime createdAt, + DateTime? decidedAt}); +} + +/// @nodoc +class __$$ToolCallImplCopyWithImpl<$Res> + extends _$ToolCallCopyWithImpl<$Res, _$ToolCallImpl> + implements _$$ToolCallImplCopyWith<$Res> { + __$$ToolCallImplCopyWithImpl( + _$ToolCallImpl _value, $Res Function(_$ToolCallImpl) _then) + : super(_value, _then); + + /// Create a copy of ToolCall + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? tool = null, + Object? params = null, + Object? description = freezed, + Object? reasoning = freezed, + Object? riskLevel = null, + Object? decision = null, + Object? modifications = freezed, + Object? result = freezed, + Object? createdAt = null, + Object? decidedAt = freezed, + }) { + return _then(_$ToolCallImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + tool: null == tool + ? _value.tool + : tool // ignore: cast_nullable_to_non_nullable + as String, + params: null == params + ? _value._params + : params // ignore: cast_nullable_to_non_nullable + as Map, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + reasoning: freezed == reasoning + ? _value.reasoning + : reasoning // ignore: cast_nullable_to_non_nullable + as String?, + riskLevel: null == riskLevel + ? _value.riskLevel + : riskLevel // ignore: cast_nullable_to_non_nullable + as RiskLevel, + decision: null == decision + ? _value.decision + : decision // ignore: cast_nullable_to_non_nullable + as ApprovalDecision, + modifications: freezed == modifications + ? _value.modifications + : modifications // ignore: cast_nullable_to_non_nullable + as String?, + result: freezed == result + ? _value._result + : result // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + decidedAt: freezed == decidedAt + ? _value.decidedAt + : decidedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ToolCallImpl implements _ToolCall { + const _$ToolCallImpl( + {required this.id, + required this.sessionId, + required this.tool, + required final Map params, + this.description, + this.reasoning, + this.riskLevel = RiskLevel.low, + this.decision = ApprovalDecision.pending, + this.modifications, + final Map? result, + required this.createdAt, + this.decidedAt}) + : _params = params, + _result = result; + + factory _$ToolCallImpl.fromJson(Map json) => + _$$ToolCallImplFromJson(json); + + @override + final String id; + @override + final String sessionId; + @override + final String tool; + final Map _params; + @override + Map get params { + if (_params is EqualUnmodifiableMapView) return _params; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_params); + } + + @override + final String? description; + @override + final String? reasoning; + @override + @JsonKey() + final RiskLevel riskLevel; + @override + @JsonKey() + final ApprovalDecision decision; + @override + final String? modifications; + final Map? _result; + @override + Map? get result { + final value = _result; + if (value == null) return null; + if (_result is EqualUnmodifiableMapView) return _result; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + final DateTime createdAt; + @override + final DateTime? decidedAt; + + @override + String toString() { + return 'ToolCall(id: $id, sessionId: $sessionId, tool: $tool, params: $params, description: $description, reasoning: $reasoning, riskLevel: $riskLevel, decision: $decision, modifications: $modifications, result: $result, createdAt: $createdAt, decidedAt: $decidedAt)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ToolCallImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId) && + (identical(other.tool, tool) || other.tool == tool) && + const DeepCollectionEquality().equals(other._params, _params) && + (identical(other.description, description) || + other.description == description) && + (identical(other.reasoning, reasoning) || + other.reasoning == reasoning) && + (identical(other.riskLevel, riskLevel) || + other.riskLevel == riskLevel) && + (identical(other.decision, decision) || + other.decision == decision) && + (identical(other.modifications, modifications) || + other.modifications == modifications) && + const DeepCollectionEquality().equals(other._result, _result) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.decidedAt, decidedAt) || + other.decidedAt == decidedAt)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + sessionId, + tool, + const DeepCollectionEquality().hash(_params), + description, + reasoning, + riskLevel, + decision, + modifications, + const DeepCollectionEquality().hash(_result), + createdAt, + decidedAt); + + /// Create a copy of ToolCall + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ToolCallImplCopyWith<_$ToolCallImpl> get copyWith => + __$$ToolCallImplCopyWithImpl<_$ToolCallImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ToolCallImplToJson( + this, + ); + } +} + +abstract class _ToolCall implements ToolCall { + const factory _ToolCall( + {required final String id, + required final String sessionId, + required final String tool, + required final Map params, + final String? description, + final String? reasoning, + final RiskLevel riskLevel, + final ApprovalDecision decision, + final String? modifications, + final Map? result, + required final DateTime createdAt, + final DateTime? decidedAt}) = _$ToolCallImpl; + + factory _ToolCall.fromJson(Map json) = + _$ToolCallImpl.fromJson; + + @override + String get id; + @override + String get sessionId; + @override + String get tool; + @override + Map get params; + @override + String? get description; + @override + String? get reasoning; + @override + RiskLevel get riskLevel; + @override + ApprovalDecision get decision; + @override + String? get modifications; + @override + Map? get result; + @override + DateTime get createdAt; + @override + DateTime? get decidedAt; + + /// Create a copy of ToolCall + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ToolCallImplCopyWith<_$ToolCallImpl> get copyWith => + throw _privateConstructorUsedError; +} + +Message _$MessageFromJson(Map json) { + return _Message.fromJson(json); +} + +/// @nodoc +mixin _$Message { + String get id => throw _privateConstructorUsedError; + String get sessionId => throw _privateConstructorUsedError; + MessageRole get role => throw _privateConstructorUsedError; + String get content => throw _privateConstructorUsedError; + MessageType get type => throw _privateConstructorUsedError; + List get parts => throw _privateConstructorUsedError; + Map? get metadata => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime? get updatedAt => throw _privateConstructorUsedError; + bool get synced => throw _privateConstructorUsedError; + + /// Serializes this Message to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $MessageCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MessageCopyWith<$Res> { + factory $MessageCopyWith(Message value, $Res Function(Message) then) = + _$MessageCopyWithImpl<$Res, Message>; + @useResult + $Res call( + {String id, + String sessionId, + MessageRole role, + String content, + MessageType type, + List parts, + Map? metadata, + DateTime createdAt, + DateTime? updatedAt, + bool synced}); +} + +/// @nodoc +class _$MessageCopyWithImpl<$Res, $Val extends Message> + implements $MessageCopyWith<$Res> { + _$MessageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? role = null, + Object? content = null, + Object? type = null, + Object? parts = null, + Object? metadata = freezed, + Object? createdAt = null, + Object? updatedAt = freezed, + Object? synced = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + role: null == role + ? _value.role + : role // ignore: cast_nullable_to_non_nullable + as MessageRole, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as MessageType, + parts: null == parts + ? _value.parts + : parts // ignore: cast_nullable_to_non_nullable + as List, + metadata: freezed == metadata + ? _value.metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + synced: null == synced + ? _value.synced + : synced // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MessageImplCopyWith<$Res> implements $MessageCopyWith<$Res> { + factory _$$MessageImplCopyWith( + _$MessageImpl value, $Res Function(_$MessageImpl) then) = + __$$MessageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String sessionId, + MessageRole role, + String content, + MessageType type, + List parts, + Map? metadata, + DateTime createdAt, + DateTime? updatedAt, + bool synced}); +} + +/// @nodoc +class __$$MessageImplCopyWithImpl<$Res> + extends _$MessageCopyWithImpl<$Res, _$MessageImpl> + implements _$$MessageImplCopyWith<$Res> { + __$$MessageImplCopyWithImpl( + _$MessageImpl _value, $Res Function(_$MessageImpl) _then) + : super(_value, _then); + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? role = null, + Object? content = null, + Object? type = null, + Object? parts = null, + Object? metadata = freezed, + Object? createdAt = null, + Object? updatedAt = freezed, + Object? synced = null, + }) { + return _then(_$MessageImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + role: null == role + ? _value.role + : role // ignore: cast_nullable_to_non_nullable + as MessageRole, + content: null == content + ? _value.content + : content // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as MessageType, + parts: null == parts + ? _value._parts + : parts // ignore: cast_nullable_to_non_nullable + as List, + metadata: freezed == metadata + ? _value._metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + synced: null == synced + ? _value.synced + : synced // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$MessageImpl implements _Message { + const _$MessageImpl( + {required this.id, + required this.sessionId, + required this.role, + required this.content, + required this.type, + required final List parts, + final Map? metadata, + required this.createdAt, + this.updatedAt, + this.synced = true}) + : _parts = parts, + _metadata = metadata; + + factory _$MessageImpl.fromJson(Map json) => + _$$MessageImplFromJson(json); + + @override + final String id; + @override + final String sessionId; + @override + final MessageRole role; + @override + final String content; + @override + final MessageType type; + final List _parts; + @override + List get parts { + if (_parts is EqualUnmodifiableListView) return _parts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_parts); + } + + final Map? _metadata; + @override + Map? get metadata { + final value = _metadata; + if (value == null) return null; + if (_metadata is EqualUnmodifiableMapView) return _metadata; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + final DateTime createdAt; + @override + final DateTime? updatedAt; + @override + @JsonKey() + final bool synced; + + @override + String toString() { + return 'Message(id: $id, sessionId: $sessionId, role: $role, content: $content, type: $type, parts: $parts, metadata: $metadata, createdAt: $createdAt, updatedAt: $updatedAt, synced: $synced)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MessageImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId) && + (identical(other.role, role) || other.role == role) && + (identical(other.content, content) || other.content == content) && + (identical(other.type, type) || other.type == type) && + const DeepCollectionEquality().equals(other._parts, _parts) && + const DeepCollectionEquality().equals(other._metadata, _metadata) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + (identical(other.synced, synced) || other.synced == synced)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + sessionId, + role, + content, + type, + const DeepCollectionEquality().hash(_parts), + const DeepCollectionEquality().hash(_metadata), + createdAt, + updatedAt, + synced); + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$MessageImplCopyWith<_$MessageImpl> get copyWith => + __$$MessageImplCopyWithImpl<_$MessageImpl>(this, _$identity); + + @override + Map toJson() { + return _$$MessageImplToJson( + this, + ); + } +} + +abstract class _Message implements Message { + const factory _Message( + {required final String id, + required final String sessionId, + required final MessageRole role, + required final String content, + required final MessageType type, + required final List parts, + final Map? metadata, + required final DateTime createdAt, + final DateTime? updatedAt, + final bool synced}) = _$MessageImpl; + + factory _Message.fromJson(Map json) = _$MessageImpl.fromJson; + + @override + String get id; + @override + String get sessionId; + @override + MessageRole get role; + @override + String get content; + @override + MessageType get type; + @override + List get parts; + @override + Map? get metadata; + @override + DateTime get createdAt; + @override + DateTime? get updatedAt; + @override + bool get synced; + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$MessageImplCopyWith<_$MessageImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/message_models.g.dart b/apps/mobile/lib/core/models/message_models.g.dart new file mode 100644 index 0000000..e25fc1c --- /dev/null +++ b/apps/mobile/lib/core/models/message_models.g.dart @@ -0,0 +1,176 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$TextPartImpl _$$TextPartImplFromJson(Map json) => + _$TextPartImpl( + content: json['content'] as String, + $type: json['runtimeType'] as String?, + ); + +Map _$$TextPartImplToJson(_$TextPartImpl instance) => + { + 'content': instance.content, + 'runtimeType': instance.$type, + }; + +_$ToolUsePartImpl _$$ToolUsePartImplFromJson(Map json) => + _$ToolUsePartImpl( + tool: json['tool'] as String, + params: json['params'] as Map, + id: json['id'] as String?, + $type: json['runtimeType'] as String?, + ); + +Map _$$ToolUsePartImplToJson(_$ToolUsePartImpl instance) => + { + 'tool': instance.tool, + 'params': instance.params, + 'id': instance.id, + 'runtimeType': instance.$type, + }; + +_$ToolResultPartImpl _$$ToolResultPartImplFromJson(Map json) => + _$ToolResultPartImpl( + toolCallId: json['toolCallId'] as String, + result: ToolResult.fromJson(json['result'] as Map), + $type: json['runtimeType'] as String?, + ); + +Map _$$ToolResultPartImplToJson( + _$ToolResultPartImpl instance) => + { + 'toolCallId': instance.toolCallId, + 'result': instance.result, + 'runtimeType': instance.$type, + }; + +_$ThinkingPartImpl _$$ThinkingPartImplFromJson(Map json) => + _$ThinkingPartImpl( + content: json['content'] as String, + $type: json['runtimeType'] as String?, + ); + +Map _$$ThinkingPartImplToJson(_$ThinkingPartImpl instance) => + { + 'content': instance.content, + 'runtimeType': instance.$type, + }; + +_$ToolResultImpl _$$ToolResultImplFromJson(Map json) => + _$ToolResultImpl( + success: json['success'] as bool, + content: json['content'] as String, + metadata: json['metadata'] as Map?, + error: json['error'] as String?, + durationMs: (json['durationMs'] as num?)?.toInt(), + ); + +Map _$$ToolResultImplToJson(_$ToolResultImpl instance) => + { + 'success': instance.success, + 'content': instance.content, + 'metadata': instance.metadata, + 'error': instance.error, + 'durationMs': instance.durationMs, + }; + +_$ToolCallImpl _$$ToolCallImplFromJson(Map json) => + _$ToolCallImpl( + id: json['id'] as String, + sessionId: json['sessionId'] as String, + tool: json['tool'] as String, + params: json['params'] as Map, + description: json['description'] as String?, + reasoning: json['reasoning'] as String?, + riskLevel: $enumDecodeNullable(_$RiskLevelEnumMap, json['riskLevel']) ?? + RiskLevel.low, + decision: + $enumDecodeNullable(_$ApprovalDecisionEnumMap, json['decision']) ?? + ApprovalDecision.pending, + modifications: json['modifications'] as String?, + result: json['result'] as Map?, + createdAt: DateTime.parse(json['createdAt'] as String), + decidedAt: json['decidedAt'] == null + ? null + : DateTime.parse(json['decidedAt'] as String), + ); + +Map _$$ToolCallImplToJson(_$ToolCallImpl instance) => + { + 'id': instance.id, + 'sessionId': instance.sessionId, + 'tool': instance.tool, + 'params': instance.params, + 'description': instance.description, + 'reasoning': instance.reasoning, + 'riskLevel': _$RiskLevelEnumMap[instance.riskLevel]!, + 'decision': _$ApprovalDecisionEnumMap[instance.decision]!, + 'modifications': instance.modifications, + 'result': instance.result, + 'createdAt': instance.createdAt.toIso8601String(), + 'decidedAt': instance.decidedAt?.toIso8601String(), + }; + +const _$RiskLevelEnumMap = { + RiskLevel.low: 'low', + RiskLevel.medium: 'medium', + RiskLevel.high: 'high', + RiskLevel.critical: 'critical', +}; + +const _$ApprovalDecisionEnumMap = { + ApprovalDecision.pending: 'pending', + ApprovalDecision.approved: 'approved', + ApprovalDecision.rejected: 'rejected', + ApprovalDecision.modified: 'modified', +}; + +_$MessageImpl _$$MessageImplFromJson(Map json) => + _$MessageImpl( + id: json['id'] as String, + sessionId: json['sessionId'] as String, + role: $enumDecode(_$MessageRoleEnumMap, json['role']), + content: json['content'] as String, + type: $enumDecode(_$MessageTypeEnumMap, json['type']), + parts: (json['parts'] as List) + .map((e) => MessagePart.fromJson(e as Map)) + .toList(), + metadata: json['metadata'] as Map?, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), + synced: json['synced'] as bool? ?? true, + ); + +Map _$$MessageImplToJson(_$MessageImpl instance) => + { + 'id': instance.id, + 'sessionId': instance.sessionId, + 'role': _$MessageRoleEnumMap[instance.role]!, + 'content': instance.content, + 'type': _$MessageTypeEnumMap[instance.type]!, + 'parts': instance.parts, + 'metadata': instance.metadata, + 'createdAt': instance.createdAt.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), + 'synced': instance.synced, + }; + +const _$MessageRoleEnumMap = { + MessageRole.user: 'user', + MessageRole.agent: 'agent', + MessageRole.system: 'system', +}; + +const _$MessageTypeEnumMap = { + MessageType.text: 'text', + MessageType.toolCall: 'toolCall', + MessageType.toolResult: 'toolResult', + MessageType.system: 'system', +}; diff --git a/apps/mobile/lib/core/models/models.dart b/apps/mobile/lib/core/models/models.dart new file mode 100644 index 0000000..737fc09 --- /dev/null +++ b/apps/mobile/lib/core/models/models.dart @@ -0,0 +1,7 @@ +export 'agent_models.dart'; +export 'file_models.dart'; +export 'git_models.dart'; +export 'hook_models.dart'; +export 'message_models.dart'; +export 'notification_models.dart'; +export 'session_models.dart'; diff --git a/apps/mobile/lib/core/models/notification_models.dart b/apps/mobile/lib/core/models/notification_models.dart new file mode 100644 index 0000000..0ffb7fb --- /dev/null +++ b/apps/mobile/lib/core/models/notification_models.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'notification_models.freezed.dart'; +part 'notification_models.g.dart'; + +enum NotificationType { approvalRequired, taskComplete, error, info } + +enum NotificationPriority { low, normal, high } + +@freezed +class AppNotification with _$AppNotification { + const factory AppNotification({ + required String id, + String? sessionId, + required NotificationType type, + required String title, + required String body, + @Default(NotificationPriority.normal) NotificationPriority priority, + Map? data, + required DateTime timestamp, + @Default(false) bool isRead, + }) = _AppNotification; + + factory AppNotification.fromJson(Map json) => + _$AppNotificationFromJson(json); +} diff --git a/apps/mobile/lib/core/models/notification_models.freezed.dart b/apps/mobile/lib/core/models/notification_models.freezed.dart new file mode 100644 index 0000000..687dcec --- /dev/null +++ b/apps/mobile/lib/core/models/notification_models.freezed.dart @@ -0,0 +1,344 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'notification_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +AppNotification _$AppNotificationFromJson(Map json) { + return _AppNotification.fromJson(json); +} + +/// @nodoc +mixin _$AppNotification { + String get id => throw _privateConstructorUsedError; + String? get sessionId => throw _privateConstructorUsedError; + NotificationType get type => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String get body => throw _privateConstructorUsedError; + NotificationPriority get priority => throw _privateConstructorUsedError; + Map? get data => throw _privateConstructorUsedError; + DateTime get timestamp => throw _privateConstructorUsedError; + bool get isRead => throw _privateConstructorUsedError; + + /// Serializes this AppNotification to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of AppNotification + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $AppNotificationCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $AppNotificationCopyWith<$Res> { + factory $AppNotificationCopyWith( + AppNotification value, $Res Function(AppNotification) then) = + _$AppNotificationCopyWithImpl<$Res, AppNotification>; + @useResult + $Res call( + {String id, + String? sessionId, + NotificationType type, + String title, + String body, + NotificationPriority priority, + Map? data, + DateTime timestamp, + bool isRead}); +} + +/// @nodoc +class _$AppNotificationCopyWithImpl<$Res, $Val extends AppNotification> + implements $AppNotificationCopyWith<$Res> { + _$AppNotificationCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of AppNotification + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = freezed, + Object? type = null, + Object? title = null, + Object? body = null, + Object? priority = null, + Object? data = freezed, + Object? timestamp = null, + Object? isRead = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: freezed == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String?, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + body: null == body + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as String, + priority: null == priority + ? _value.priority + : priority // ignore: cast_nullable_to_non_nullable + as NotificationPriority, + data: freezed == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as Map?, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + isRead: null == isRead + ? _value.isRead + : isRead // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$AppNotificationImplCopyWith<$Res> + implements $AppNotificationCopyWith<$Res> { + factory _$$AppNotificationImplCopyWith(_$AppNotificationImpl value, + $Res Function(_$AppNotificationImpl) then) = + __$$AppNotificationImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String? sessionId, + NotificationType type, + String title, + String body, + NotificationPriority priority, + Map? data, + DateTime timestamp, + bool isRead}); +} + +/// @nodoc +class __$$AppNotificationImplCopyWithImpl<$Res> + extends _$AppNotificationCopyWithImpl<$Res, _$AppNotificationImpl> + implements _$$AppNotificationImplCopyWith<$Res> { + __$$AppNotificationImplCopyWithImpl( + _$AppNotificationImpl _value, $Res Function(_$AppNotificationImpl) _then) + : super(_value, _then); + + /// Create a copy of AppNotification + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = freezed, + Object? type = null, + Object? title = null, + Object? body = null, + Object? priority = null, + Object? data = freezed, + Object? timestamp = null, + Object? isRead = null, + }) { + return _then(_$AppNotificationImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: freezed == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String?, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as NotificationType, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + body: null == body + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as String, + priority: null == priority + ? _value.priority + : priority // ignore: cast_nullable_to_non_nullable + as NotificationPriority, + data: freezed == data + ? _value._data + : data // ignore: cast_nullable_to_non_nullable + as Map?, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + isRead: null == isRead + ? _value.isRead + : isRead // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$AppNotificationImpl implements _AppNotification { + const _$AppNotificationImpl( + {required this.id, + this.sessionId, + required this.type, + required this.title, + required this.body, + this.priority = NotificationPriority.normal, + final Map? data, + required this.timestamp, + this.isRead = false}) + : _data = data; + + factory _$AppNotificationImpl.fromJson(Map json) => + _$$AppNotificationImplFromJson(json); + + @override + final String id; + @override + final String? sessionId; + @override + final NotificationType type; + @override + final String title; + @override + final String body; + @override + @JsonKey() + final NotificationPriority priority; + final Map? _data; + @override + Map? get data { + final value = _data; + if (value == null) return null; + if (_data is EqualUnmodifiableMapView) return _data; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + final DateTime timestamp; + @override + @JsonKey() + final bool isRead; + + @override + String toString() { + return 'AppNotification(id: $id, sessionId: $sessionId, type: $type, title: $title, body: $body, priority: $priority, data: $data, timestamp: $timestamp, isRead: $isRead)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AppNotificationImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId) && + (identical(other.type, type) || other.type == type) && + (identical(other.title, title) || other.title == title) && + (identical(other.body, body) || other.body == body) && + (identical(other.priority, priority) || + other.priority == priority) && + const DeepCollectionEquality().equals(other._data, _data) && + (identical(other.timestamp, timestamp) || + other.timestamp == timestamp) && + (identical(other.isRead, isRead) || other.isRead == isRead)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, sessionId, type, title, body, + priority, const DeepCollectionEquality().hash(_data), timestamp, isRead); + + /// Create a copy of AppNotification + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AppNotificationImplCopyWith<_$AppNotificationImpl> get copyWith => + __$$AppNotificationImplCopyWithImpl<_$AppNotificationImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$AppNotificationImplToJson( + this, + ); + } +} + +abstract class _AppNotification implements AppNotification { + const factory _AppNotification( + {required final String id, + final String? sessionId, + required final NotificationType type, + required final String title, + required final String body, + final NotificationPriority priority, + final Map? data, + required final DateTime timestamp, + final bool isRead}) = _$AppNotificationImpl; + + factory _AppNotification.fromJson(Map json) = + _$AppNotificationImpl.fromJson; + + @override + String get id; + @override + String? get sessionId; + @override + NotificationType get type; + @override + String get title; + @override + String get body; + @override + NotificationPriority get priority; + @override + Map? get data; + @override + DateTime get timestamp; + @override + bool get isRead; + + /// Create a copy of AppNotification + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AppNotificationImplCopyWith<_$AppNotificationImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/notification_models.g.dart b/apps/mobile/lib/core/models/notification_models.g.dart new file mode 100644 index 0000000..c437e51 --- /dev/null +++ b/apps/mobile/lib/core/models/notification_models.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$AppNotificationImpl _$$AppNotificationImplFromJson( + Map json) => + _$AppNotificationImpl( + id: json['id'] as String, + sessionId: json['sessionId'] as String?, + type: $enumDecode(_$NotificationTypeEnumMap, json['type']), + title: json['title'] as String, + body: json['body'] as String, + priority: $enumDecodeNullable( + _$NotificationPriorityEnumMap, json['priority']) ?? + NotificationPriority.normal, + data: json['data'] as Map?, + timestamp: DateTime.parse(json['timestamp'] as String), + isRead: json['isRead'] as bool? ?? false, + ); + +Map _$$AppNotificationImplToJson( + _$AppNotificationImpl instance) => + { + 'id': instance.id, + 'sessionId': instance.sessionId, + 'type': _$NotificationTypeEnumMap[instance.type]!, + 'title': instance.title, + 'body': instance.body, + 'priority': _$NotificationPriorityEnumMap[instance.priority]!, + 'data': instance.data, + 'timestamp': instance.timestamp.toIso8601String(), + 'isRead': instance.isRead, + }; + +const _$NotificationTypeEnumMap = { + NotificationType.approvalRequired: 'approvalRequired', + NotificationType.taskComplete: 'taskComplete', + NotificationType.error: 'error', + NotificationType.info: 'info', +}; + +const _$NotificationPriorityEnumMap = { + NotificationPriority.low: 'low', + NotificationPriority.normal: 'normal', + NotificationPriority.high: 'high', +}; diff --git a/apps/mobile/lib/core/models/session_models.dart b/apps/mobile/lib/core/models/session_models.dart new file mode 100644 index 0000000..bd1380b --- /dev/null +++ b/apps/mobile/lib/core/models/session_models.dart @@ -0,0 +1,52 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'session_models.freezed.dart'; +part 'session_models.g.dart'; + +enum SessionStatus { active, paused, closed } + +enum SessionEventType { + userMessage, + agentMessage, + toolUse, + toolResult, + sessionStart, + sessionEnd, + hookEvent, +} + +@freezed +class ChatSession with _$ChatSession { + const factory ChatSession({ + required String id, + required String agentType, + String? agentId, + @Default('') String title, + required String workingDirectory, + String? branch, + @Default(SessionStatus.active) SessionStatus status, + required DateTime createdAt, + DateTime? lastMessageAt, + DateTime? updatedAt, + @Default(true) bool synced, + }) = _ChatSession; + + factory ChatSession.fromJson(Map json) => + _$ChatSessionFromJson(json); +} + +@freezed +class SessionEvent with _$SessionEvent { + const factory SessionEvent({ + required String id, + required String sessionId, + required SessionEventType eventType, + required String title, + String? description, + required DateTime timestamp, + Map? metadata, + }) = _SessionEvent; + + factory SessionEvent.fromJson(Map json) => + _$SessionEventFromJson(json); +} diff --git a/apps/mobile/lib/core/models/session_models.freezed.dart b/apps/mobile/lib/core/models/session_models.freezed.dart new file mode 100644 index 0000000..f1165ee --- /dev/null +++ b/apps/mobile/lib/core/models/session_models.freezed.dart @@ -0,0 +1,674 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'session_models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +ChatSession _$ChatSessionFromJson(Map json) { + return _ChatSession.fromJson(json); +} + +/// @nodoc +mixin _$ChatSession { + String get id => throw _privateConstructorUsedError; + String get agentType => throw _privateConstructorUsedError; + String? get agentId => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String get workingDirectory => throw _privateConstructorUsedError; + String? get branch => throw _privateConstructorUsedError; + SessionStatus get status => throw _privateConstructorUsedError; + DateTime get createdAt => throw _privateConstructorUsedError; + DateTime? get lastMessageAt => throw _privateConstructorUsedError; + DateTime? get updatedAt => throw _privateConstructorUsedError; + bool get synced => throw _privateConstructorUsedError; + + /// Serializes this ChatSession to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of ChatSession + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ChatSessionCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ChatSessionCopyWith<$Res> { + factory $ChatSessionCopyWith( + ChatSession value, $Res Function(ChatSession) then) = + _$ChatSessionCopyWithImpl<$Res, ChatSession>; + @useResult + $Res call( + {String id, + String agentType, + String? agentId, + String title, + String workingDirectory, + String? branch, + SessionStatus status, + DateTime createdAt, + DateTime? lastMessageAt, + DateTime? updatedAt, + bool synced}); +} + +/// @nodoc +class _$ChatSessionCopyWithImpl<$Res, $Val extends ChatSession> + implements $ChatSessionCopyWith<$Res> { + _$ChatSessionCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ChatSession + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? agentType = null, + Object? agentId = freezed, + Object? title = null, + Object? workingDirectory = null, + Object? branch = freezed, + Object? status = null, + Object? createdAt = null, + Object? lastMessageAt = freezed, + Object? updatedAt = freezed, + Object? synced = null, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + agentType: null == agentType + ? _value.agentType + : agentType // ignore: cast_nullable_to_non_nullable + as String, + agentId: freezed == agentId + ? _value.agentId + : agentId // ignore: cast_nullable_to_non_nullable + as String?, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + workingDirectory: null == workingDirectory + ? _value.workingDirectory + : workingDirectory // ignore: cast_nullable_to_non_nullable + as String, + branch: freezed == branch + ? _value.branch + : branch // ignore: cast_nullable_to_non_nullable + as String?, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as SessionStatus, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + lastMessageAt: freezed == lastMessageAt + ? _value.lastMessageAt + : lastMessageAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + synced: null == synced + ? _value.synced + : synced // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ChatSessionImplCopyWith<$Res> + implements $ChatSessionCopyWith<$Res> { + factory _$$ChatSessionImplCopyWith( + _$ChatSessionImpl value, $Res Function(_$ChatSessionImpl) then) = + __$$ChatSessionImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String agentType, + String? agentId, + String title, + String workingDirectory, + String? branch, + SessionStatus status, + DateTime createdAt, + DateTime? lastMessageAt, + DateTime? updatedAt, + bool synced}); +} + +/// @nodoc +class __$$ChatSessionImplCopyWithImpl<$Res> + extends _$ChatSessionCopyWithImpl<$Res, _$ChatSessionImpl> + implements _$$ChatSessionImplCopyWith<$Res> { + __$$ChatSessionImplCopyWithImpl( + _$ChatSessionImpl _value, $Res Function(_$ChatSessionImpl) _then) + : super(_value, _then); + + /// Create a copy of ChatSession + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? agentType = null, + Object? agentId = freezed, + Object? title = null, + Object? workingDirectory = null, + Object? branch = freezed, + Object? status = null, + Object? createdAt = null, + Object? lastMessageAt = freezed, + Object? updatedAt = freezed, + Object? synced = null, + }) { + return _then(_$ChatSessionImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + agentType: null == agentType + ? _value.agentType + : agentType // ignore: cast_nullable_to_non_nullable + as String, + agentId: freezed == agentId + ? _value.agentId + : agentId // ignore: cast_nullable_to_non_nullable + as String?, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + workingDirectory: null == workingDirectory + ? _value.workingDirectory + : workingDirectory // ignore: cast_nullable_to_non_nullable + as String, + branch: freezed == branch + ? _value.branch + : branch // ignore: cast_nullable_to_non_nullable + as String?, + status: null == status + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as SessionStatus, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + lastMessageAt: freezed == lastMessageAt + ? _value.lastMessageAt + : lastMessageAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + synced: null == synced + ? _value.synced + : synced // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$ChatSessionImpl implements _ChatSession { + const _$ChatSessionImpl( + {required this.id, + required this.agentType, + this.agentId, + this.title = '', + required this.workingDirectory, + this.branch, + this.status = SessionStatus.active, + required this.createdAt, + this.lastMessageAt, + this.updatedAt, + this.synced = true}); + + factory _$ChatSessionImpl.fromJson(Map json) => + _$$ChatSessionImplFromJson(json); + + @override + final String id; + @override + final String agentType; + @override + final String? agentId; + @override + @JsonKey() + final String title; + @override + final String workingDirectory; + @override + final String? branch; + @override + @JsonKey() + final SessionStatus status; + @override + final DateTime createdAt; + @override + final DateTime? lastMessageAt; + @override + final DateTime? updatedAt; + @override + @JsonKey() + final bool synced; + + @override + String toString() { + return 'ChatSession(id: $id, agentType: $agentType, agentId: $agentId, title: $title, workingDirectory: $workingDirectory, branch: $branch, status: $status, createdAt: $createdAt, lastMessageAt: $lastMessageAt, updatedAt: $updatedAt, synced: $synced)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ChatSessionImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.agentType, agentType) || + other.agentType == agentType) && + (identical(other.agentId, agentId) || other.agentId == agentId) && + (identical(other.title, title) || other.title == title) && + (identical(other.workingDirectory, workingDirectory) || + other.workingDirectory == workingDirectory) && + (identical(other.branch, branch) || other.branch == branch) && + (identical(other.status, status) || other.status == status) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.lastMessageAt, lastMessageAt) || + other.lastMessageAt == lastMessageAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && + (identical(other.synced, synced) || other.synced == synced)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + id, + agentType, + agentId, + title, + workingDirectory, + branch, + status, + createdAt, + lastMessageAt, + updatedAt, + synced); + + /// Create a copy of ChatSession + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ChatSessionImplCopyWith<_$ChatSessionImpl> get copyWith => + __$$ChatSessionImplCopyWithImpl<_$ChatSessionImpl>(this, _$identity); + + @override + Map toJson() { + return _$$ChatSessionImplToJson( + this, + ); + } +} + +abstract class _ChatSession implements ChatSession { + const factory _ChatSession( + {required final String id, + required final String agentType, + final String? agentId, + final String title, + required final String workingDirectory, + final String? branch, + final SessionStatus status, + required final DateTime createdAt, + final DateTime? lastMessageAt, + final DateTime? updatedAt, + final bool synced}) = _$ChatSessionImpl; + + factory _ChatSession.fromJson(Map json) = + _$ChatSessionImpl.fromJson; + + @override + String get id; + @override + String get agentType; + @override + String? get agentId; + @override + String get title; + @override + String get workingDirectory; + @override + String? get branch; + @override + SessionStatus get status; + @override + DateTime get createdAt; + @override + DateTime? get lastMessageAt; + @override + DateTime? get updatedAt; + @override + bool get synced; + + /// Create a copy of ChatSession + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ChatSessionImplCopyWith<_$ChatSessionImpl> get copyWith => + throw _privateConstructorUsedError; +} + +SessionEvent _$SessionEventFromJson(Map json) { + return _SessionEvent.fromJson(json); +} + +/// @nodoc +mixin _$SessionEvent { + String get id => throw _privateConstructorUsedError; + String get sessionId => throw _privateConstructorUsedError; + SessionEventType get eventType => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String? get description => throw _privateConstructorUsedError; + DateTime get timestamp => throw _privateConstructorUsedError; + Map? get metadata => throw _privateConstructorUsedError; + + /// Serializes this SessionEvent to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of SessionEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $SessionEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SessionEventCopyWith<$Res> { + factory $SessionEventCopyWith( + SessionEvent value, $Res Function(SessionEvent) then) = + _$SessionEventCopyWithImpl<$Res, SessionEvent>; + @useResult + $Res call( + {String id, + String sessionId, + SessionEventType eventType, + String title, + String? description, + DateTime timestamp, + Map? metadata}); +} + +/// @nodoc +class _$SessionEventCopyWithImpl<$Res, $Val extends SessionEvent> + implements $SessionEventCopyWith<$Res> { + _$SessionEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of SessionEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? eventType = null, + Object? title = null, + Object? description = freezed, + Object? timestamp = null, + Object? metadata = freezed, + }) { + return _then(_value.copyWith( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + eventType: null == eventType + ? _value.eventType + : eventType // ignore: cast_nullable_to_non_nullable + as SessionEventType, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + metadata: freezed == metadata + ? _value.metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SessionEventImplCopyWith<$Res> + implements $SessionEventCopyWith<$Res> { + factory _$$SessionEventImplCopyWith( + _$SessionEventImpl value, $Res Function(_$SessionEventImpl) then) = + __$$SessionEventImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + String sessionId, + SessionEventType eventType, + String title, + String? description, + DateTime timestamp, + Map? metadata}); +} + +/// @nodoc +class __$$SessionEventImplCopyWithImpl<$Res> + extends _$SessionEventCopyWithImpl<$Res, _$SessionEventImpl> + implements _$$SessionEventImplCopyWith<$Res> { + __$$SessionEventImplCopyWithImpl( + _$SessionEventImpl _value, $Res Function(_$SessionEventImpl) _then) + : super(_value, _then); + + /// Create a copy of SessionEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? sessionId = null, + Object? eventType = null, + Object? title = null, + Object? description = freezed, + Object? timestamp = null, + Object? metadata = freezed, + }) { + return _then(_$SessionEventImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + sessionId: null == sessionId + ? _value.sessionId + : sessionId // ignore: cast_nullable_to_non_nullable + as String, + eventType: null == eventType + ? _value.eventType + : eventType // ignore: cast_nullable_to_non_nullable + as SessionEventType, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + description: freezed == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + timestamp: null == timestamp + ? _value.timestamp + : timestamp // ignore: cast_nullable_to_non_nullable + as DateTime, + metadata: freezed == metadata + ? _value._metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SessionEventImpl implements _SessionEvent { + const _$SessionEventImpl( + {required this.id, + required this.sessionId, + required this.eventType, + required this.title, + this.description, + required this.timestamp, + final Map? metadata}) + : _metadata = metadata; + + factory _$SessionEventImpl.fromJson(Map json) => + _$$SessionEventImplFromJson(json); + + @override + final String id; + @override + final String sessionId; + @override + final SessionEventType eventType; + @override + final String title; + @override + final String? description; + @override + final DateTime timestamp; + final Map? _metadata; + @override + Map? get metadata { + final value = _metadata; + if (value == null) return null; + if (_metadata is EqualUnmodifiableMapView) return _metadata; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + String toString() { + return 'SessionEvent(id: $id, sessionId: $sessionId, eventType: $eventType, title: $title, description: $description, timestamp: $timestamp, metadata: $metadata)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SessionEventImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.sessionId, sessionId) || + other.sessionId == sessionId) && + (identical(other.eventType, eventType) || + other.eventType == eventType) && + (identical(other.title, title) || other.title == title) && + (identical(other.description, description) || + other.description == description) && + (identical(other.timestamp, timestamp) || + other.timestamp == timestamp) && + const DeepCollectionEquality().equals(other._metadata, _metadata)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, sessionId, eventType, title, + description, timestamp, const DeepCollectionEquality().hash(_metadata)); + + /// Create a copy of SessionEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SessionEventImplCopyWith<_$SessionEventImpl> get copyWith => + __$$SessionEventImplCopyWithImpl<_$SessionEventImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SessionEventImplToJson( + this, + ); + } +} + +abstract class _SessionEvent implements SessionEvent { + const factory _SessionEvent( + {required final String id, + required final String sessionId, + required final SessionEventType eventType, + required final String title, + final String? description, + required final DateTime timestamp, + final Map? metadata}) = _$SessionEventImpl; + + factory _SessionEvent.fromJson(Map json) = + _$SessionEventImpl.fromJson; + + @override + String get id; + @override + String get sessionId; + @override + SessionEventType get eventType; + @override + String get title; + @override + String? get description; + @override + DateTime get timestamp; + @override + Map? get metadata; + + /// Create a copy of SessionEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SessionEventImplCopyWith<_$SessionEventImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/apps/mobile/lib/core/models/session_models.g.dart b/apps/mobile/lib/core/models/session_models.g.dart new file mode 100644 index 0000000..7e7ac7c --- /dev/null +++ b/apps/mobile/lib/core/models/session_models.g.dart @@ -0,0 +1,80 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$ChatSessionImpl _$$ChatSessionImplFromJson(Map json) => + _$ChatSessionImpl( + id: json['id'] as String, + agentType: json['agentType'] as String, + agentId: json['agentId'] as String?, + title: json['title'] as String? ?? '', + workingDirectory: json['workingDirectory'] as String, + branch: json['branch'] as String?, + status: $enumDecodeNullable(_$SessionStatusEnumMap, json['status']) ?? + SessionStatus.active, + createdAt: DateTime.parse(json['createdAt'] as String), + lastMessageAt: json['lastMessageAt'] == null + ? null + : DateTime.parse(json['lastMessageAt'] as String), + updatedAt: json['updatedAt'] == null + ? null + : DateTime.parse(json['updatedAt'] as String), + synced: json['synced'] as bool? ?? true, + ); + +Map _$$ChatSessionImplToJson(_$ChatSessionImpl instance) => + { + 'id': instance.id, + 'agentType': instance.agentType, + 'agentId': instance.agentId, + 'title': instance.title, + 'workingDirectory': instance.workingDirectory, + 'branch': instance.branch, + 'status': _$SessionStatusEnumMap[instance.status]!, + 'createdAt': instance.createdAt.toIso8601String(), + 'lastMessageAt': instance.lastMessageAt?.toIso8601String(), + 'updatedAt': instance.updatedAt?.toIso8601String(), + 'synced': instance.synced, + }; + +const _$SessionStatusEnumMap = { + SessionStatus.active: 'active', + SessionStatus.paused: 'paused', + SessionStatus.closed: 'closed', +}; + +_$SessionEventImpl _$$SessionEventImplFromJson(Map json) => + _$SessionEventImpl( + id: json['id'] as String, + sessionId: json['sessionId'] as String, + eventType: $enumDecode(_$SessionEventTypeEnumMap, json['eventType']), + title: json['title'] as String, + description: json['description'] as String?, + timestamp: DateTime.parse(json['timestamp'] as String), + metadata: json['metadata'] as Map?, + ); + +Map _$$SessionEventImplToJson(_$SessionEventImpl instance) => + { + 'id': instance.id, + 'sessionId': instance.sessionId, + 'eventType': _$SessionEventTypeEnumMap[instance.eventType]!, + 'title': instance.title, + 'description': instance.description, + 'timestamp': instance.timestamp.toIso8601String(), + 'metadata': instance.metadata, + }; + +const _$SessionEventTypeEnumMap = { + SessionEventType.userMessage: 'userMessage', + SessionEventType.agentMessage: 'agentMessage', + SessionEventType.toolUse: 'toolUse', + SessionEventType.toolResult: 'toolResult', + SessionEventType.sessionStart: 'sessionStart', + SessionEventType.sessionEnd: 'sessionEnd', + SessionEventType.hookEvent: 'hookEvent', +}; diff --git a/apps/mobile/lib/core/monitoring/analytics_service.dart b/apps/mobile/lib/core/monitoring/analytics_service.dart new file mode 100644 index 0000000..a8302ce --- /dev/null +++ b/apps/mobile/lib/core/monitoring/analytics_service.dart @@ -0,0 +1,47 @@ +// Simple event analytics — no external SDK, logs locally and optionally to a self-hosted endpoint +// User must opt in (checked against AppPreferences) + +import 'package:flutter/foundation.dart'; + +class AnalyticsEvent { + final String name; + final Map properties; + final DateTime timestamp; + + const AnalyticsEvent({required this.name, this.properties = const {}, required this.timestamp}); + + Map toJson() => { + 'name': name, + 'properties': properties, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class AnalyticsService { + static bool _enabled = false; + static final List _buffer = []; + static const int _maxBuffer = 100; + + static void setEnabled(bool enabled) { _enabled = enabled; } + static bool get isEnabled => _enabled; + + static void track(String event, {Map? properties}) { + if (!_enabled) return; + final e = AnalyticsEvent(name: event, properties: properties ?? {}, timestamp: DateTime.now()); + _buffer.add(e); + if (_buffer.length > _maxBuffer) _buffer.removeAt(0); + debugPrint('[Analytics] $event ${properties ?? {}}'); + } + + // Pre-defined events + static void trackSessionStarted(String agentType) => track('session_started', properties: {'agent_type': agentType}); + static void trackMessageSent() => track('message_sent'); + static void trackApprovalDecision(String decision) => track('approval_decision', properties: {'decision': decision}); + static void trackToolCardExpanded(String tool) => track('tool_card_expanded', properties: {'tool': tool}); + static void trackDiffViewed() => track('diff_viewed'); + static void trackVoiceInputUsed() => track('voice_input_used'); + static void trackAgentAdded(String type) => track('agent_added', properties: {'type': type}); + + static List get buffer => List.unmodifiable(_buffer); + static void clearBuffer() => _buffer.clear(); +} diff --git a/apps/mobile/lib/core/monitoring/sentry_service.dart b/apps/mobile/lib/core/monitoring/sentry_service.dart new file mode 100644 index 0000000..2ec1e18 --- /dev/null +++ b/apps/mobile/lib/core/monitoring/sentry_service.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +class SentryService { + static const String _dsn = String.fromEnvironment( + 'SENTRY_DSN', + defaultValue: '', + ); + + static bool get isConfigured => _dsn.isNotEmpty; + + // Initialize Sentry — call before runApp + static Future init(AppRunner appRunner) async { + if (!isConfigured) { + appRunner(); + return; + } + await SentryFlutter.init( + (options) { + options.dsn = _dsn; + options.environment = const String.fromEnvironment('APP_ENV', defaultValue: 'development'); + options.tracesSampleRate = 0.2; + options.profilesSampleRate = 0.1; + options.attachScreenshot = true; + options.enableAutoPerformanceTracing = true; + }, + appRunner: appRunner, + ); + } + + // Capture an exception manually + static Future captureException(Object exception, {StackTrace? stackTrace}) async { + if (!isConfigured) return; + await Sentry.captureException(exception, stackTrace: stackTrace); + } + + // Add breadcrumb + static void addBreadcrumb(String message, {String? category, Map? data}) { + if (!isConfigured) return; + Sentry.addBreadcrumb(Breadcrumb( + message: message, + category: category, + data: data, + )); + } + + // Set user context + static void setUser(String? id, String? username) { + if (!isConfigured) return; + Sentry.configureScope((scope) { + scope.setUser(id != null ? SentryUser(id: id, username: username) : null); + }); + } +} + +typedef AppRunner = void Function(); diff --git a/apps/mobile/lib/core/network/bridge_connection_validator.dart b/apps/mobile/lib/core/network/bridge_connection_validator.dart new file mode 100644 index 0000000..af27e50 --- /dev/null +++ b/apps/mobile/lib/core/network/bridge_connection_validator.dart @@ -0,0 +1,62 @@ +class BridgeConnectionValidationResult { + const BridgeConnectionValidationResult._({ + required this.isValid, + this.errorMessage, + }); + + const BridgeConnectionValidationResult.valid() : this._(isValid: true); + + const BridgeConnectionValidationResult.invalid(String message) + : this._(isValid: false, errorMessage: message); + + final bool isValid; + final String? errorMessage; +} + +class BridgeConnectionValidator { + const BridgeConnectionValidator._(); + + static BridgeConnectionValidationResult validate({ + required String url, + required String token, + }) { + final normalizedUrl = url.trim(); + final normalizedToken = token.trim(); + + if (normalizedUrl.isEmpty) { + return const BridgeConnectionValidationResult.invalid( + 'Bridge URL is required.', + ); + } + + if (normalizedToken.isEmpty) { + return const BridgeConnectionValidationResult.invalid( + 'Bridge auth token is required.', + ); + } + + final uri = Uri.tryParse(normalizedUrl); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return const BridgeConnectionValidationResult.invalid( + 'Enter a valid bridge URL.', + ); + } + + if (uri.scheme != 'wss') { + return const BridgeConnectionValidationResult.invalid( + 'Bridge URL must use wss:// to match the security contract.', + ); + } + + return const BridgeConnectionValidationResult.valid(); + } +} + +class BridgeConnectionException implements Exception { + const BridgeConnectionException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/apps/mobile/lib/core/network/bridge_socket.dart b/apps/mobile/lib/core/network/bridge_socket.dart new file mode 100644 index 0000000..e9aa030 --- /dev/null +++ b/apps/mobile/lib/core/network/bridge_socket.dart @@ -0,0 +1,46 @@ +// Re-exports for backwards compatibility with generated code +export '../providers/websocket_provider.dart' + show webSocketServiceProvider, connectionStatusProvider, bridgeMessagesProvider; +export 'connection_state.dart' show ConnectionStatus; + +// Aliases +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'websocket_messages.dart'; +import 'websocket_service.dart'; +import '../providers/websocket_provider.dart'; +import 'connection_state.dart'; + +/// Thin facade over [WebSocketService] that exposes a map-based API used by +/// feature providers generated before the typed-message redesign. +class BridgeSocket { + BridgeSocket(this._service); + + final WebSocketService _service; + + /// Stream of decoded JSON payloads from the bridge. + Stream> get messageStream => + _service.messages.map((msg) => msg.toJson()); + + /// Send a raw map as a JSON frame. + void send(Map data) { + _service.sendRaw(jsonEncode(data)); + } + + ConnectionStatus get currentStatus => _service.currentStatus; +} + +final _bridgeSocketProvider = Provider((ref) { + final service = ref.watch(webSocketServiceProvider); + return BridgeSocket(service); +}); + +/// Alias: bridgeSocketProvider exposes the [BridgeSocket] facade. +final bridgeSocketProvider = _bridgeSocketProvider; + +/// Alias: bridgeSocketStateProvider = connectionStatusProvider +final bridgeSocketStateProvider = connectionStatusProvider; + +/// Alias type: BridgeSocketState = ConnectionStatus +typedef BridgeSocketState = ConnectionStatus; diff --git a/apps/mobile/lib/core/network/connection_state.dart b/apps/mobile/lib/core/network/connection_state.dart new file mode 100644 index 0000000..ae2893a --- /dev/null +++ b/apps/mobile/lib/core/network/connection_state.dart @@ -0,0 +1,50 @@ +enum ConnectionStatus { + disconnected, + connecting, + connected, + reconnecting, + error, +} + +class ConnectionState { + final ConnectionStatus status; + final String? bridgeUrl; + final DateTime? lastConnectedAt; + final int reconnectAttempts; + final String? errorMessage; + + const ConnectionState({ + required this.status, + this.bridgeUrl, + this.lastConnectedAt, + this.reconnectAttempts = 0, + this.errorMessage, + }); + + const ConnectionState.initial() + : status = ConnectionStatus.disconnected, + bridgeUrl = null, + lastConnectedAt = null, + reconnectAttempts = 0, + errorMessage = null; + + ConnectionState copyWith({ + ConnectionStatus? status, + String? bridgeUrl, + DateTime? lastConnectedAt, + int? reconnectAttempts, + String? errorMessage, + }) { + return ConnectionState( + status: status ?? this.status, + bridgeUrl: bridgeUrl ?? this.bridgeUrl, + lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt, + reconnectAttempts: reconnectAttempts ?? this.reconnectAttempts, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + String toString() => + 'ConnectionState(status: $status, url: $bridgeUrl, attempts: $reconnectAttempts)'; +} diff --git a/apps/mobile/lib/core/network/websocket_messages.dart b/apps/mobile/lib/core/network/websocket_messages.dart new file mode 100644 index 0000000..860f750 --- /dev/null +++ b/apps/mobile/lib/core/network/websocket_messages.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'package:uuid/uuid.dart'; + +enum BridgeMessageType { + // Connection + auth, + connectionAck, + connectionError, + heartbeatPing, + heartbeatPong, + // Sessions + sessionStart, + sessionReady, + sessionEnd, + // Chat + message, + streamStart, + streamChunk, + streamEnd, + // Tools + toolCall, + claudeEvent, + approvalRequired, + approvalResponse, + toolResult, + // Git + gitStatusRequest, + gitStatusResponse, + gitCommit, + gitDiff, + gitDiffResponse, + // Files + fileList, + fileListResponse, + fileRead, + fileReadResponse, + // Notifications + notification, + notificationAck, + // Errors + error, +} + +/// Converts a string message type (snake_case) to [BridgeMessageType]. +BridgeMessageType _typeFromString(String type) { + return switch (type) { + 'auth' => BridgeMessageType.auth, + 'connection_ack' => BridgeMessageType.connectionAck, + 'connection_error' => BridgeMessageType.connectionError, + 'heartbeat_ping' => BridgeMessageType.heartbeatPing, + 'heartbeat_pong' => BridgeMessageType.heartbeatPong, + 'session_start' => BridgeMessageType.sessionStart, + 'session_ready' => BridgeMessageType.sessionReady, + 'session_end' => BridgeMessageType.sessionEnd, + 'message' => BridgeMessageType.message, + 'stream_start' => BridgeMessageType.streamStart, + 'stream_chunk' => BridgeMessageType.streamChunk, + 'stream_end' => BridgeMessageType.streamEnd, + 'tool_call' => BridgeMessageType.toolCall, + 'claude_event' => BridgeMessageType.claudeEvent, + 'approval_required' => BridgeMessageType.approvalRequired, + 'approval_response' => BridgeMessageType.approvalResponse, + 'tool_result' => BridgeMessageType.toolResult, + 'git_status_request' => BridgeMessageType.gitStatusRequest, + 'git_status_response' => BridgeMessageType.gitStatusResponse, + 'git_commit' => BridgeMessageType.gitCommit, + 'git_diff' => BridgeMessageType.gitDiff, + 'git_diff_response' => BridgeMessageType.gitDiffResponse, + 'file_list' => BridgeMessageType.fileList, + 'file_list_response' => BridgeMessageType.fileListResponse, + 'file_read' => BridgeMessageType.fileRead, + 'file_read_response' => BridgeMessageType.fileReadResponse, + 'notification' => BridgeMessageType.notification, + 'notification_ack' => BridgeMessageType.notificationAck, + 'error' => BridgeMessageType.error, + _ => BridgeMessageType.error, + }; +} + +String _typeToString(BridgeMessageType type) { + return switch (type) { + BridgeMessageType.auth => 'auth', + BridgeMessageType.connectionAck => 'connection_ack', + BridgeMessageType.connectionError => 'connection_error', + BridgeMessageType.heartbeatPing => 'heartbeat_ping', + BridgeMessageType.heartbeatPong => 'heartbeat_pong', + BridgeMessageType.sessionStart => 'session_start', + BridgeMessageType.sessionReady => 'session_ready', + BridgeMessageType.sessionEnd => 'session_end', + BridgeMessageType.message => 'message', + BridgeMessageType.streamStart => 'stream_start', + BridgeMessageType.streamChunk => 'stream_chunk', + BridgeMessageType.streamEnd => 'stream_end', + BridgeMessageType.toolCall => 'tool_call', + BridgeMessageType.claudeEvent => 'claude_event', + BridgeMessageType.approvalRequired => 'approval_required', + BridgeMessageType.approvalResponse => 'approval_response', + BridgeMessageType.toolResult => 'tool_result', + BridgeMessageType.gitStatusRequest => 'git_status_request', + BridgeMessageType.gitStatusResponse => 'git_status_response', + BridgeMessageType.gitCommit => 'git_commit', + BridgeMessageType.gitDiff => 'git_diff', + BridgeMessageType.gitDiffResponse => 'git_diff_response', + BridgeMessageType.fileList => 'file_list', + BridgeMessageType.fileListResponse => 'file_list_response', + BridgeMessageType.fileRead => 'file_read', + BridgeMessageType.fileReadResponse => 'file_read_response', + BridgeMessageType.notification => 'notification', + BridgeMessageType.notificationAck => 'notification_ack', + BridgeMessageType.error => 'error', + }; +} + +const _uuid = Uuid(); + +class BridgeMessage { + final BridgeMessageType type; + final String? id; + final DateTime timestamp; + final Map payload; + + const BridgeMessage({ + required this.type, + this.id, + required this.timestamp, + required this.payload, + }); + + // --------------------------------------------------------------------------- + // Factories + // --------------------------------------------------------------------------- + + factory BridgeMessage.auth({ + required String token, + required String clientVersion, + required String platform, + }) { + return BridgeMessage( + type: BridgeMessageType.auth, + id: 'auth-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'token': token, + 'client_version': clientVersion, + 'platform': platform, + }, + ); + } + + factory BridgeMessage.heartbeatPing() { + return BridgeMessage( + type: BridgeMessageType.heartbeatPing, + timestamp: DateTime.now().toUtc(), + payload: {}, + ); + } + + factory BridgeMessage.sessionStart({ + required String agent, + String? sessionId, + required String workingDirectory, + bool resume = false, + }) { + return BridgeMessage( + type: BridgeMessageType.sessionStart, + id: 'req-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'agent': agent, + 'session_id': sessionId, + 'working_directory': workingDirectory, + 'resume': resume, + }, + ); + } + + factory BridgeMessage.message({ + required String sessionId, + required String content, + String role = 'user', + }) { + return BridgeMessage( + type: BridgeMessageType.message, + id: 'msg-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'content': content, + 'role': role, + }, + ); + } + + factory BridgeMessage.approvalResponse({ + required String sessionId, + required String toolCallId, + required String decision, + Map? modifications, + String? correlationId, + }) { + return BridgeMessage( + type: BridgeMessageType.approvalResponse, + id: correlationId ?? 'resp-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'tool_call_id': toolCallId, + 'decision': decision, + 'modifications': modifications, + }, + ); + } + + factory BridgeMessage.sessionEnd({ + required String sessionId, + String reason = 'user_request', + }) { + return BridgeMessage( + type: BridgeMessageType.sessionEnd, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'reason': reason, + }, + ); + } + + factory BridgeMessage.gitStatusRequest({required String sessionId}) { + return BridgeMessage( + type: BridgeMessageType.gitStatusRequest, + id: 'git-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: {'session_id': sessionId}, + ); + } + + factory BridgeMessage.gitCommit({ + required String sessionId, + required String commitMessage, + List? files, + }) { + return BridgeMessage( + type: BridgeMessageType.gitCommit, + id: 'git-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'message': commitMessage, + 'files': files, + }, + ); + } + + factory BridgeMessage.gitDiff({ + required String sessionId, + List? files, + bool cached = false, + }) { + return BridgeMessage( + type: BridgeMessageType.gitDiff, + id: 'git-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'files': files, + 'cached': cached, + }, + ); + } + + factory BridgeMessage.fileList({ + required String sessionId, + required String path, + }) { + return BridgeMessage( + type: BridgeMessageType.fileList, + id: 'file-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: {'session_id': sessionId, 'path': path}, + ); + } + + factory BridgeMessage.fileRead({ + required String sessionId, + required String path, + int offset = 0, + int? limit, + }) { + return BridgeMessage( + type: BridgeMessageType.fileRead, + id: 'file-${_uuid.v4()}', + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': sessionId, + 'path': path, + 'offset': offset, + if (limit != null) 'limit': limit, + }, + ); + } + + factory BridgeMessage.notificationAck({ + required List notificationIds, + }) { + return BridgeMessage( + type: BridgeMessageType.notificationAck, + timestamp: DateTime.now().toUtc(), + payload: {'notification_ids': notificationIds}, + ); + } + + // --------------------------------------------------------------------------- + // Serialization + // --------------------------------------------------------------------------- + + factory BridgeMessage.fromJson(Map json) { + return BridgeMessage( + type: _typeFromString(json['type'] as String), + id: json['id'] as String?, + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now().toUtc(), + payload: (json['payload'] as Map?) ?? {}, + ); + } + + Map toJson() { + final map = { + 'type': _typeToString(type), + 'timestamp': timestamp.toIso8601String(), + 'payload': payload, + }; + if (id != null) map['id'] = id; + return map; + } + + String toJsonString() => jsonEncode(toJson()); + + @override + String toString() => 'BridgeMessage(type: $type, id: $id)'; +} diff --git a/apps/mobile/lib/core/network/websocket_service.dart b/apps/mobile/lib/core/network/websocket_service.dart new file mode 100644 index 0000000..2d99309 --- /dev/null +++ b/apps/mobile/lib/core/network/websocket_service.dart @@ -0,0 +1,338 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'bridge_connection_validator.dart'; +import 'connection_state.dart'; +import 'websocket_messages.dart'; + +typedef WebSocketChannelFactory = WebSocketChannel Function(Uri uri); + +/// WebSocket client service for communication with the ReCursor bridge server. +/// +/// Responsibilities: +/// - Connect / disconnect lifecycle +/// - Authentication handshake +/// - Heartbeat (ping every 15 s, expect pong within 10 s) +/// - Automatic reconnect with exponential back-off (1 → 2 → 4 → 8 … max 30 s) +/// - Serialize outgoing [BridgeMessage] to JSON +/// - Parse incoming JSON into [BridgeMessage] +class WebSocketService { + WebSocketService({WebSocketChannelFactory? channelFactory}) + : _channelFactory = channelFactory ?? WebSocketChannel.connect; + + static const int _heartbeatIntervalSeconds = 15; + static const int _heartbeatTimeoutSeconds = 10; + static const int _authTimeoutSeconds = 10; + static const int _maxReconnectDelaySeconds = 30; + + final WebSocketChannelFactory _channelFactory; + + WebSocketChannel? _channel; + String? _url; + String? _token; + Map? _lastConnectionAckPayload; + + final StreamController _messageController = + StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); + + ConnectionStatus _status = ConnectionStatus.disconnected; + int _reconnectAttempts = 0; + bool _intentionalDisconnect = false; + bool _authFailed = false; + Completer? _authCompleter; + + Timer? _heartbeatTimer; + Timer? _pongTimeoutTimer; + Timer? _reconnectTimer; + + Stream get messages => _messageController.stream; + Stream get connectionStatus => _statusController.stream; + ConnectionStatus get currentStatus => _status; + Map? get lastConnectionAckPayload => + _lastConnectionAckPayload; + + Future connect({required String url, required String token}) async { + final validation = BridgeConnectionValidator.validate( + url: url, + token: token, + ); + if (!validation.isValid) { + throw BridgeConnectionException(validation.errorMessage!); + } + + _url = url.trim(); + _token = token.trim(); + _intentionalDisconnect = false; + _authFailed = false; + _reconnectAttempts = 0; + await _doConnect(); + } + + Future _doConnect() async { + if (_url == null || _token == null) { + throw const BridgeConnectionException( + 'Bridge connection details are incomplete.', + ); + } + + if (_status != ConnectionStatus.reconnecting) { + _setStatus(ConnectionStatus.connecting); + } + + _cleanUp(closeChannel: true, cancelReconnect: false); + _authCompleter = Completer(); + + try { + final Uri uri = Uri.parse(_url!); + final WebSocketChannel channel = _channelFactory(uri); + _channel = channel; + + channel.stream.listen( + _onRawMessage, + onError: _onError, + onDone: _onDone, + cancelOnError: false, + ); + + await channel.ready; + + _sendInternal( + BridgeMessage.auth( + token: _token!, + clientVersion: '0.1.0', + platform: _platformString(), + ), + ); + + await _authCompleter!.future.timeout( + const Duration(seconds: _authTimeoutSeconds), + onTimeout: () { + throw const BridgeConnectionException( + 'Bridge authentication timed out.', + ); + }, + ); + + _startHeartbeat(); + } catch (error) { + _completeAuthError(error); + _setStatus(ConnectionStatus.error); + _cleanUp(closeChannel: true, cancelReconnect: false); + _scheduleReconnectIfNeeded(); + if (error is BridgeConnectionException) { + rethrow; + } + throw BridgeConnectionException('Failed to connect to bridge: $error'); + } + } + + void disconnect() { + _intentionalDisconnect = true; + _authFailed = false; + _completeAuthError( + const BridgeConnectionException('Bridge connection closed.'), + ); + _cleanUp(); + _setStatus(ConnectionStatus.disconnected); + } + + bool send(BridgeMessage message) { + if (_channel == null || _status != ConnectionStatus.connected) { + return false; + } + return _sendInternal(message); + } + + void sendRaw(String json) { + _channel?.sink.add(json); + } + + bool _sendInternal(BridgeMessage message) { + final WebSocketChannel? channel = _channel; + if (channel == null) { + return false; + } + + channel.sink.add(message.toJsonString()); + return true; + } + + void _onRawMessage(dynamic data) { + try { + final Map json = + jsonDecode(data as String) as Map; + final BridgeMessage message = BridgeMessage.fromJson(json); + + if (message.type == BridgeMessageType.connectionAck) { + _reconnectAttempts = 0; + _authFailed = false; + _lastConnectionAckPayload = Map.unmodifiable( + Map.from(message.payload), + ); + _setStatus(ConnectionStatus.connected); + _completeAuthSuccess(); + return; + } + + if (message.type == BridgeMessageType.connectionError) { + _authFailed = true; + final String detail = message.payload['message'] as String? ?? + 'Bridge rejected the auth token.'; + _completeAuthError(BridgeConnectionException(detail)); + _setStatus(ConnectionStatus.error); + _cleanUp(closeChannel: true, cancelReconnect: false); + return; + } + + if (message.type == BridgeMessageType.heartbeatPong) { + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = null; + return; + } + + if (!_messageController.isClosed) { + _messageController.add(message); + } + } catch (_) { + // Ignore malformed frames from the bridge. + } + } + + void _onError(Object error) { + _completeAuthError( + BridgeConnectionException('Bridge socket error: $error')); + _setStatus(ConnectionStatus.error); + _cleanUp(closeChannel: false, cancelReconnect: false); + _scheduleReconnectIfNeeded(); + } + + void _onDone() { + _completeAuthError( + const BridgeConnectionException('Bridge connection closed.'), + ); + _setStatus(ConnectionStatus.disconnected); + _cleanUp(closeChannel: false, cancelReconnect: false); + _scheduleReconnectIfNeeded(); + } + + void _startHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic( + const Duration(seconds: _heartbeatIntervalSeconds), + (_) => _sendPing(), + ); + } + + void _sendPing() { + if (_status != ConnectionStatus.connected) { + return; + } + + final bool sent = send(BridgeMessage.heartbeatPing()); + if (!sent) { + return; + } + + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = Timer( + const Duration(seconds: _heartbeatTimeoutSeconds), + _onPongTimeout, + ); + } + + void _onPongTimeout() { + _cleanUp(closeChannel: true, cancelReconnect: false); + _setStatus(ConnectionStatus.reconnecting); + _scheduleReconnectIfNeeded(); + } + + void _scheduleReconnectIfNeeded() { + if (_intentionalDisconnect || _authFailed) { + return; + } + _scheduleReconnect(); + } + + void _scheduleReconnect() { + _reconnectTimer?.cancel(); + + final int delaySeconds = _exponentialDelay(_reconnectAttempts); + _reconnectAttempts++; + _setStatus(ConnectionStatus.reconnecting); + + _reconnectTimer = Timer(Duration(seconds: delaySeconds), () async { + if (_intentionalDisconnect || _authFailed) { + return; + } + + try { + await _doConnect(); + } catch (_) { + // Reconnect failures are surfaced through connectionStatus. + } + }); + } + + int _exponentialDelay(int attempt) { + final int delay = (1 << attempt).clamp(1, _maxReconnectDelaySeconds); + return delay; + } + + void _completeAuthSuccess() { + final Completer? completer = _authCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + void _completeAuthError(Object error) { + final Completer? completer = _authCompleter; + if (completer != null && !completer.isCompleted) { + completer.completeError(error); + } + } + + void _cleanUp({ + bool closeChannel = true, + bool cancelReconnect = true, + }) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + _pongTimeoutTimer?.cancel(); + _pongTimeoutTimer = null; + if (cancelReconnect) { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + if (closeChannel) { + _channel?.sink.close(); + _channel = null; + } + } + + void _setStatus(ConnectionStatus status) { + _status = status; + if (!_statusController.isClosed) { + _statusController.add(status); + } + } + + String _platformString() { + return 'flutter'; + } + + void dispose() { + _intentionalDisconnect = true; + _completeAuthError( + const BridgeConnectionException('Bridge connection disposed.'), + ); + _cleanUp(); + _messageController.close(); + _statusController.close(); + } +} diff --git a/apps/mobile/lib/core/notifications/notification_center.dart b/apps/mobile/lib/core/notifications/notification_center.dart new file mode 100644 index 0000000..992a0d9 --- /dev/null +++ b/apps/mobile/lib/core/notifications/notification_center.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import '../models/notification_models.dart'; + +/// In-memory notification center. +/// Maintains the list of [AppNotification] items and broadcasts changes. +class NotificationCenter { + final _notifications = []; + final _controller = + StreamController>.broadcast(); + + Stream> get notifications => _controller.stream; + + List get currentNotifications => + List.unmodifiable(_notifications); + + int get unreadCount => + _notifications.where((n) => !n.isRead).length; + + void addNotification(AppNotification n) { + // Deduplicate by id. + _notifications.removeWhere((existing) => existing.id == n.id); + _notifications.insert(0, n); + _emit(); + } + + void markRead(String id) { + final index = _notifications.indexWhere((n) => n.id == id); + if (index == -1) return; + _notifications[index] = _notifications[index].copyWith(isRead: true); + _emit(); + } + + void markAllRead() { + for (var i = 0; i < _notifications.length; i++) { + _notifications[i] = _notifications[i].copyWith(isRead: true); + } + _emit(); + } + + void _emit() { + _controller.add(List.unmodifiable(_notifications)); + } + + void dispose() { + _controller.close(); + } +} diff --git a/apps/mobile/lib/core/notifications/notification_handler.dart b/apps/mobile/lib/core/notifications/notification_handler.dart new file mode 100644 index 0000000..534740a --- /dev/null +++ b/apps/mobile/lib/core/notifications/notification_handler.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/scheduler.dart'; + +import '../models/notification_models.dart'; +import '../network/websocket_messages.dart'; +import 'notification_center.dart'; +import 'notification_service.dart'; + +/// Routes incoming bridge [BridgeMessage] notifications. +/// - Foreground: adds to [NotificationCenter]. +/// - Background: calls [NotificationService.showNotification]. +class NotificationHandler { + NotificationHandler({ + required NotificationCenter center, + required NotificationService service, + }) : _center = center, + _service = service; + + final NotificationCenter _center; + final NotificationService _service; + + final _controller = + StreamController.broadcast(); + + Stream get notificationStream => _controller.stream; + + Future handle(BridgeMessage message) async { + if (message.type != BridgeMessageType.notification) return; + + final payload = message.payload; + final notification = _parseNotification(message.id, payload); + if (notification == null) return; + + _controller.add(notification); + + // Determine if the app is in the foreground. + final lifecycleState = SchedulerBinding.instance?.lifecycleState; + final isForeground = lifecycleState == AppLifecycleState.resumed; + + if (isForeground) { + _center.addNotification(notification); + } else { + _center.addNotification(notification); + await _service.showNotification(notification); + } + } + + AppNotification? _parseNotification( + String? id, Map payload) { + try { + final typeString = + payload['notification_type'] as String? ?? 'info'; + final type = _parseType(typeString); + final priorityString = payload['priority'] as String? ?? 'normal'; + final priority = _parsePriority(priorityString); + + return AppNotification( + id: id ?? payload['notification_id'] as String? ?? '', + sessionId: payload['session_id'] as String?, + type: type, + title: payload['title'] as String? ?? '', + body: payload['body'] as String? ?? '', + priority: priority, + data: payload['data'] as Map?, + timestamp: DateTime.now().toUtc(), + ); + } catch (_) { + return null; + } + } + + NotificationType _parseType(String value) { + return switch (value) { + 'approval_required' => NotificationType.approvalRequired, + 'task_complete' => NotificationType.taskComplete, + 'error' => NotificationType.error, + _ => NotificationType.info, + }; + } + + NotificationPriority _parsePriority(String value) { + return switch (value) { + 'low' => NotificationPriority.low, + 'high' => NotificationPriority.high, + _ => NotificationPriority.normal, + }; + } + + void dispose() { + _controller.close(); + } +} diff --git a/apps/mobile/lib/core/notifications/notification_service.dart b/apps/mobile/lib/core/notifications/notification_service.dart new file mode 100644 index 0000000..e823ad5 --- /dev/null +++ b/apps/mobile/lib/core/notifications/notification_service.dart @@ -0,0 +1,82 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../models/notification_models.dart'; + +/// Thin wrapper around [FlutterLocalNotificationsPlugin]. +/// Handles permission requests and displaying local notifications. +class NotificationService { + final _plugin = FlutterLocalNotificationsPlugin(); + + Future init() async { + const androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _plugin.initialize(initSettings); + + // Request Android 13+ notification permission. + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + } + + Future showNotification(AppNotification notification) async { + final androidDetails = AndroidNotificationDetails( + 'recursor_main', + 'ReCursor', + channelDescription: 'ReCursor agent notifications', + importance: _toAndroidImportance(notification.priority), + priority: _toAndroidPriority(notification.priority), + ); + const iosDetails = DarwinNotificationDetails(); + + final details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + // Use a stable integer derived from the UUID to identify the notification. + final id = notification.id.hashCode & 0x7FFFFFFF; + + await _plugin.show( + id, + notification.title, + notification.body, + details, + payload: notification.id, + ); + } + + Future cancelNotification(String id) async { + await _plugin.cancel(id.hashCode & 0x7FFFFFFF); + } + + Future cancelAll() async { + await _plugin.cancelAll(); + } + + Importance _toAndroidImportance(NotificationPriority priority) { + return switch (priority) { + NotificationPriority.low => Importance.low, + NotificationPriority.normal => Importance.defaultImportance, + NotificationPriority.high => Importance.high, + }; + } + + Priority _toAndroidPriority(NotificationPriority priority) { + return switch (priority) { + NotificationPriority.low => Priority.low, + NotificationPriority.normal => Priority.defaultPriority, + NotificationPriority.high => Priority.high, + }; + } +} diff --git a/apps/mobile/lib/core/providers/bridge_provider.dart b/apps/mobile/lib/core/providers/bridge_provider.dart new file mode 100644 index 0000000..c07d5e5 --- /dev/null +++ b/apps/mobile/lib/core/providers/bridge_provider.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../network/connection_state.dart'; +import '../network/websocket_service.dart'; +import 'websocket_provider.dart'; + +/// Notifier that exposes connect/disconnect actions on the shared +/// [WebSocketService]. UI reads [bridgeProvider] for connection state and +/// calls `.notifier.connect(url, token)` to initiate a connection. +class BridgeNotifier extends Notifier { + @override + ConnectionStatus build() { + final service = ref.watch(webSocketServiceProvider); + // Sync initial status. + final initial = service.currentStatus; + + // Keep state in sync with the service stream. + final sub = service.connectionStatus.listen((s) { + state = s; + }); + ref.onDispose(sub.cancel); + + return initial; + } + + /// Connect to the bridge at [url] using [token]. + Future connect(String url, String token) { + final service = ref.read(webSocketServiceProvider); + return service.connect(url: url, token: token); + } + + /// Disconnect from the bridge. + void disconnect() { + final service = ref.read(webSocketServiceProvider); + service.disconnect(); + } +} + +final bridgeProvider = + NotifierProvider(BridgeNotifier.new); diff --git a/apps/mobile/lib/core/providers/database_provider.dart b/apps/mobile/lib/core/providers/database_provider.dart new file mode 100644 index 0000000..597a6bc --- /dev/null +++ b/apps/mobile/lib/core/providers/database_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../storage/database.dart'; + +final databaseProvider = Provider((ref) { + final db = AppDatabase(); + ref.onDispose(db.close); + return db; +}); diff --git a/apps/mobile/lib/core/providers/notification_provider.dart b/apps/mobile/lib/core/providers/notification_provider.dart new file mode 100644 index 0000000..81e4bbd --- /dev/null +++ b/apps/mobile/lib/core/providers/notification_provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/notification_models.dart'; +import '../notifications/notification_center.dart'; + +final notificationCenterProvider = Provider((ref) { + final center = NotificationCenter(); + ref.onDispose(center.dispose); + return center; +}); + +final notificationsProvider = StreamProvider>((ref) { + return ref.watch(notificationCenterProvider).notifications; +}); + +final unreadCountProvider = Provider((ref) { + return ref.watch(notificationCenterProvider).unreadCount; +}); diff --git a/apps/mobile/lib/core/providers/preferences_provider.dart b/apps/mobile/lib/core/providers/preferences_provider.dart new file mode 100644 index 0000000..2738690 --- /dev/null +++ b/apps/mobile/lib/core/providers/preferences_provider.dart @@ -0,0 +1,3 @@ +// appPreferencesProvider is declared in theme_provider.dart and overridden +// in main.dart with an initialized AppPreferences instance. +export 'theme_provider.dart' show appPreferencesProvider; diff --git a/apps/mobile/lib/core/providers/sync_queue_provider.dart b/apps/mobile/lib/core/providers/sync_queue_provider.dart new file mode 100644 index 0000000..dff3d06 --- /dev/null +++ b/apps/mobile/lib/core/providers/sync_queue_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../sync/sync_queue.dart'; +import 'database_provider.dart'; + +final syncQueueServiceProvider = Provider((ref) { + final database = ref.watch(databaseProvider); + return SyncQueueService(database: database); +}); diff --git a/apps/mobile/lib/core/providers/theme_provider.dart b/apps/mobile/lib/core/providers/theme_provider.dart new file mode 100644 index 0000000..4d0e9e7 --- /dev/null +++ b/apps/mobile/lib/core/providers/theme_provider.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../storage/preferences.dart'; + +// --------------------------------------------------------------------------- +// Preferences provider — must be overridden in ProviderScope with a real +// AppPreferences instance that has already called init(). +// --------------------------------------------------------------------------- + +final appPreferencesProvider = Provider((ref) { + throw UnimplementedError('appPreferencesProvider must be overridden in ProviderScope'); +}); + +// --------------------------------------------------------------------------- +// themeModeProvider — persists to AppPreferences when mutated +// --------------------------------------------------------------------------- + +final themeModeProvider = StateNotifierProvider<_ThemeModeNotifier, ThemeMode>((ref) { + final prefs = ref.watch(appPreferencesProvider); + return _ThemeModeNotifier(prefs); +}); + +class _ThemeModeNotifier extends StateNotifier { + final AppPreferences _prefs; + + _ThemeModeNotifier(this._prefs) : super(_prefs.getThemeMode()); + + Future setThemeMode(ThemeMode mode) async { + state = mode; + await _prefs.setThemeMode(mode); + } +} + +// --------------------------------------------------------------------------- +// highContrastProvider — persists to AppPreferences when mutated +// --------------------------------------------------------------------------- + +final highContrastProvider = StateNotifierProvider<_HighContrastNotifier, bool>((ref) { + final prefs = ref.watch(appPreferencesProvider); + return _HighContrastNotifier(prefs); +}); + +class _HighContrastNotifier extends StateNotifier { + final AppPreferences _prefs; + + _HighContrastNotifier(this._prefs) : super(_prefs.getHighContrast()); + + Future setHighContrast(bool val) async { + state = val; + await _prefs.setHighContrast(val); + } +} diff --git a/apps/mobile/lib/core/providers/token_storage_provider.dart b/apps/mobile/lib/core/providers/token_storage_provider.dart new file mode 100644 index 0000000..01c95de --- /dev/null +++ b/apps/mobile/lib/core/providers/token_storage_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../storage/secure_token_storage.dart'; + +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); + +final tokenStorageProvider = Provider((ref) { + return SecureTokenStorage(ref.read(secureStorageProvider)); +}); diff --git a/apps/mobile/lib/core/providers/websocket_provider.dart b/apps/mobile/lib/core/providers/websocket_provider.dart new file mode 100644 index 0000000..0034b3c --- /dev/null +++ b/apps/mobile/lib/core/providers/websocket_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../network/connection_state.dart'; +import '../network/websocket_messages.dart'; +import '../network/websocket_service.dart'; + +final webSocketServiceProvider = Provider((ref) { + final service = WebSocketService(); + ref.onDispose(service.dispose); + return service; +}); + +final connectionStatusProvider = StreamProvider((ref) { + final service = ref.watch(webSocketServiceProvider); + return service.connectionStatus; +}); + +final bridgeMessagesProvider = StreamProvider((ref) { + final service = ref.watch(webSocketServiceProvider); + return service.messages; +}); diff --git a/apps/mobile/lib/core/storage/daos/message_dao.dart b/apps/mobile/lib/core/storage/daos/message_dao.dart new file mode 100644 index 0000000..195f388 --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/message_dao.dart @@ -0,0 +1,45 @@ +import 'package:drift/drift.dart'; + +import '../database.dart'; +import '../tables/messages_table.dart'; + +part 'message_dao.g.dart'; + +@DriftAccessor(tables: [Messages]) +class MessageDao extends DatabaseAccessor + with _$MessageDaoMixin { + MessageDao(super.db); + + /// Watch all messages for a session, ordered by creation time ascending. + Stream> watchMessagesForSession(String sessionId) { + return (select(messages) + ..where((m) => m.sessionId.equals(sessionId)) + ..orderBy([(m) => OrderingTerm.asc(m.createdAt)])) + .watch(); + } + + /// Fetch all messages for a session (one-shot). + Future> getMessagesForSession(String sessionId) { + return (select(messages) + ..where((m) => m.sessionId.equals(sessionId)) + ..orderBy([(m) => OrderingTerm.asc(m.createdAt)])) + .get(); + } + + /// Insert a new message row. + Future insertMessage(MessagesCompanion message) async { + await into(messages).insert(message); + } + + /// Update an existing message row. + Future updateMessage(MessagesCompanion message) async { + await into(messages).insertOnConflictUpdate(message); + } + + /// Delete all messages belonging to [sessionId]. + Future deleteMessagesForSession(String sessionId) async { + await (delete(messages) + ..where((m) => m.sessionId.equals(sessionId))) + .go(); + } +} diff --git a/apps/mobile/lib/core/storage/daos/message_dao.g.dart b/apps/mobile/lib/core/storage/daos/message_dao.g.dart new file mode 100644 index 0000000..dcbfe5d --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/message_dao.g.dart @@ -0,0 +1,9 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_dao.dart'; + +// ignore_for_file: type=lint +mixin _$MessageDaoMixin on DatabaseAccessor { + $SessionsTable get sessions => attachedDatabase.sessions; + $MessagesTable get messages => attachedDatabase.messages; +} diff --git a/apps/mobile/lib/core/storage/daos/session_dao.dart b/apps/mobile/lib/core/storage/daos/session_dao.dart new file mode 100644 index 0000000..412aeca --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/session_dao.dart @@ -0,0 +1,43 @@ +import 'package:drift/drift.dart'; + +import '../database.dart'; +import '../tables/sessions_table.dart'; + +part 'session_dao.g.dart'; + +@DriftAccessor(tables: [Sessions]) +class SessionDao extends DatabaseAccessor + with _$SessionDaoMixin { + SessionDao(super.db); + + /// Watch all sessions ordered by most recently updated. + Stream> watchAllSessions() { + return (select(sessions) + ..orderBy([(s) => OrderingTerm.desc(s.updatedAt)])) + .watch(); + } + + /// Fetch a single session by ID. + Future getSession(String id) { + return (select(sessions)..where((s) => s.id.equals(id))) + .getSingleOrNull(); + } + + /// Insert or update a session row. + Future upsertSession(SessionsCompanion session) async { + await into(sessions).insertOnConflictUpdate(session); + } + + /// Delete a session by ID. + Future deleteSession(String id) async { + await (delete(sessions)..where((s) => s.id.equals(id))).go(); + } + + /// Return the count of active sessions. + Future getActiveSessionCount() async { + final query = select(sessions) + ..where((s) => s.status.equals('active')); + final rows = await query.get(); + return rows.length; + } +} diff --git a/apps/mobile/lib/core/storage/daos/session_dao.g.dart b/apps/mobile/lib/core/storage/daos/session_dao.g.dart new file mode 100644 index 0000000..635e721 --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/session_dao.g.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session_dao.dart'; + +// ignore_for_file: type=lint +mixin _$SessionDaoMixin on DatabaseAccessor { + $SessionsTable get sessions => attachedDatabase.sessions; +} diff --git a/apps/mobile/lib/core/storage/daos/sync_dao.dart b/apps/mobile/lib/core/storage/daos/sync_dao.dart new file mode 100644 index 0000000..41e7cc8 --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/sync_dao.dart @@ -0,0 +1,50 @@ +import 'package:drift/drift.dart'; + +import '../database.dart'; +import '../tables/sync_queue_table.dart'; + +part 'sync_dao.g.dart'; + +@DriftAccessor(tables: [SyncQueue]) +class SyncDao extends DatabaseAccessor with _$SyncDaoMixin { + SyncDao(super.db); + + /// Return all items that have not yet been successfully synced. + Future> getPendingItems() { + return (select(syncQueue) + ..where((q) => q.synced.equals(false)) + ..orderBy([(q) => OrderingTerm.asc(q.createdAt)])) + .get(); + } + + /// Add a new item to the sync queue. + Future enqueue(SyncQueueCompanion item) async { + await into(syncQueue).insert(item); + } + + /// Mark an item as successfully synced. + Future markSynced(int id) async { + await (update(syncQueue)..where((q) => q.id.equals(id))).write( + const SyncQueueCompanion(synced: Value(true)), + ); + } + + /// Increment retry count and record the last error for an item. + Future incrementRetry(int id, String error) async { + final item = await (select(syncQueue)..where((q) => q.id.equals(id))) + .getSingleOrNull(); + if (item == null) return; + + await (update(syncQueue)..where((q) => q.id.equals(id))).write( + SyncQueueCompanion( + retryCount: Value(item.retryCount + 1), + lastError: Value(error), + ), + ); + } + + /// Remove all items that have been successfully synced. + Future clearSynced() async { + await (delete(syncQueue)..where((q) => q.synced.equals(true))).go(); + } +} diff --git a/apps/mobile/lib/core/storage/daos/sync_dao.g.dart b/apps/mobile/lib/core/storage/daos/sync_dao.g.dart new file mode 100644 index 0000000..821bb34 --- /dev/null +++ b/apps/mobile/lib/core/storage/daos/sync_dao.g.dart @@ -0,0 +1,8 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sync_dao.dart'; + +// ignore_for_file: type=lint +mixin _$SyncDaoMixin on DatabaseAccessor { + $SyncQueueTable get syncQueue => attachedDatabase.syncQueue; +} diff --git a/apps/mobile/lib/core/storage/database.dart b/apps/mobile/lib/core/storage/database.dart new file mode 100644 index 0000000..ff10836 --- /dev/null +++ b/apps/mobile/lib/core/storage/database.dart @@ -0,0 +1,47 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:drift_flutter/drift_flutter.dart'; + +import 'daos/message_dao.dart'; +import 'daos/session_dao.dart'; +import 'daos/sync_dao.dart'; +import 'tables/agents_table.dart'; +import 'tables/approvals_table.dart'; +import 'tables/messages_table.dart'; +import 'tables/sessions_table.dart'; +import 'tables/sync_queue_table.dart'; + +part 'database.g.dart'; + +@DriftDatabase( + tables: [ + Sessions, + Messages, + Agents, + Approvals, + SyncQueue, + ], + daos: [ + SessionDao, + MessageDao, + SyncDao, + ], +) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + AppDatabase.forTesting(super.executor); + + factory AppDatabase.inMemory() { + return AppDatabase.forTesting(NativeDatabase.memory()); + } + + @override + int get schemaVersion => 1; +} + +LazyDatabase _openConnection() { + return LazyDatabase(() async { + return driftDatabase(name: 'recursor_app'); + }); +} diff --git a/apps/mobile/lib/core/storage/database.g.dart b/apps/mobile/lib/core/storage/database.g.dart new file mode 100644 index 0000000..a281ad4 --- /dev/null +++ b/apps/mobile/lib/core/storage/database.g.dart @@ -0,0 +1,4291 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $SessionsTable extends Sessions with TableInfo<$SessionsTable, Session> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SessionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _agentTypeMeta = + const VerificationMeta('agentType'); + @override + late final GeneratedColumn agentType = GeneratedColumn( + 'agent_type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _agentIdMeta = + const VerificationMeta('agentId'); + @override + late final GeneratedColumn agentId = GeneratedColumn( + 'agent_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('')); + static const VerificationMeta _workingDirectoryMeta = + const VerificationMeta('workingDirectory'); + @override + late final GeneratedColumn workingDirectory = GeneratedColumn( + 'working_directory', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _branchMeta = const VerificationMeta('branch'); + @override + late final GeneratedColumn branch = GeneratedColumn( + 'branch', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _lastMessageAtMeta = + const VerificationMeta('lastMessageAt'); + @override + late final GeneratedColumn lastMessageAt = + GeneratedColumn('last_message_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _updatedAtMeta = + const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _syncedMeta = const VerificationMeta('synced'); + @override + late final GeneratedColumn synced = GeneratedColumn( + 'synced', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("synced" IN (0, 1))'), + defaultValue: const Constant(true)); + @override + List get $columns => [ + id, + agentType, + agentId, + title, + workingDirectory, + branch, + status, + createdAt, + lastMessageAt, + updatedAt, + synced + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sessions'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('agent_type')) { + context.handle(_agentTypeMeta, + agentType.isAcceptableOrUnknown(data['agent_type']!, _agentTypeMeta)); + } else if (isInserting) { + context.missing(_agentTypeMeta); + } + if (data.containsKey('agent_id')) { + context.handle(_agentIdMeta, + agentId.isAcceptableOrUnknown(data['agent_id']!, _agentIdMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta)); + } + if (data.containsKey('working_directory')) { + context.handle( + _workingDirectoryMeta, + workingDirectory.isAcceptableOrUnknown( + data['working_directory']!, _workingDirectoryMeta)); + } else if (isInserting) { + context.missing(_workingDirectoryMeta); + } + if (data.containsKey('branch')) { + context.handle(_branchMeta, + branch.isAcceptableOrUnknown(data['branch']!, _branchMeta)); + } + if (data.containsKey('status')) { + context.handle(_statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta)); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('last_message_at')) { + context.handle( + _lastMessageAtMeta, + lastMessageAt.isAcceptableOrUnknown( + data['last_message_at']!, _lastMessageAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('synced')) { + context.handle(_syncedMeta, + synced.isAcceptableOrUnknown(data['synced']!, _syncedMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Session map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Session( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + agentType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}agent_type'])!, + agentId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}agent_id']), + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + workingDirectory: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}working_directory'])!, + branch: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}branch']), + status: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}status'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + lastMessageAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_message_at']), + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + synced: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}synced'])!, + ); + } + + @override + $SessionsTable createAlias(String alias) { + return $SessionsTable(attachedDatabase, alias); + } +} + +class Session extends DataClass implements Insertable { + final String id; + final String agentType; + final String? agentId; + final String title; + final String workingDirectory; + final String? branch; + + /// "active" | "paused" | "closed" + final String status; + final DateTime createdAt; + final DateTime? lastMessageAt; + final DateTime updatedAt; + final bool synced; + const Session( + {required this.id, + required this.agentType, + this.agentId, + required this.title, + required this.workingDirectory, + this.branch, + required this.status, + required this.createdAt, + this.lastMessageAt, + required this.updatedAt, + required this.synced}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['agent_type'] = Variable(agentType); + if (!nullToAbsent || agentId != null) { + map['agent_id'] = Variable(agentId); + } + map['title'] = Variable(title); + map['working_directory'] = Variable(workingDirectory); + if (!nullToAbsent || branch != null) { + map['branch'] = Variable(branch); + } + map['status'] = Variable(status); + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || lastMessageAt != null) { + map['last_message_at'] = Variable(lastMessageAt); + } + map['updated_at'] = Variable(updatedAt); + map['synced'] = Variable(synced); + return map; + } + + SessionsCompanion toCompanion(bool nullToAbsent) { + return SessionsCompanion( + id: Value(id), + agentType: Value(agentType), + agentId: agentId == null && nullToAbsent + ? const Value.absent() + : Value(agentId), + title: Value(title), + workingDirectory: Value(workingDirectory), + branch: + branch == null && nullToAbsent ? const Value.absent() : Value(branch), + status: Value(status), + createdAt: Value(createdAt), + lastMessageAt: lastMessageAt == null && nullToAbsent + ? const Value.absent() + : Value(lastMessageAt), + updatedAt: Value(updatedAt), + synced: Value(synced), + ); + } + + factory Session.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Session( + id: serializer.fromJson(json['id']), + agentType: serializer.fromJson(json['agentType']), + agentId: serializer.fromJson(json['agentId']), + title: serializer.fromJson(json['title']), + workingDirectory: serializer.fromJson(json['workingDirectory']), + branch: serializer.fromJson(json['branch']), + status: serializer.fromJson(json['status']), + createdAt: serializer.fromJson(json['createdAt']), + lastMessageAt: serializer.fromJson(json['lastMessageAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + synced: serializer.fromJson(json['synced']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'agentType': serializer.toJson(agentType), + 'agentId': serializer.toJson(agentId), + 'title': serializer.toJson(title), + 'workingDirectory': serializer.toJson(workingDirectory), + 'branch': serializer.toJson(branch), + 'status': serializer.toJson(status), + 'createdAt': serializer.toJson(createdAt), + 'lastMessageAt': serializer.toJson(lastMessageAt), + 'updatedAt': serializer.toJson(updatedAt), + 'synced': serializer.toJson(synced), + }; + } + + Session copyWith( + {String? id, + String? agentType, + Value agentId = const Value.absent(), + String? title, + String? workingDirectory, + Value branch = const Value.absent(), + String? status, + DateTime? createdAt, + Value lastMessageAt = const Value.absent(), + DateTime? updatedAt, + bool? synced}) => + Session( + id: id ?? this.id, + agentType: agentType ?? this.agentType, + agentId: agentId.present ? agentId.value : this.agentId, + title: title ?? this.title, + workingDirectory: workingDirectory ?? this.workingDirectory, + branch: branch.present ? branch.value : this.branch, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + lastMessageAt: + lastMessageAt.present ? lastMessageAt.value : this.lastMessageAt, + updatedAt: updatedAt ?? this.updatedAt, + synced: synced ?? this.synced, + ); + Session copyWithCompanion(SessionsCompanion data) { + return Session( + id: data.id.present ? data.id.value : this.id, + agentType: data.agentType.present ? data.agentType.value : this.agentType, + agentId: data.agentId.present ? data.agentId.value : this.agentId, + title: data.title.present ? data.title.value : this.title, + workingDirectory: data.workingDirectory.present + ? data.workingDirectory.value + : this.workingDirectory, + branch: data.branch.present ? data.branch.value : this.branch, + status: data.status.present ? data.status.value : this.status, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastMessageAt: data.lastMessageAt.present + ? data.lastMessageAt.value + : this.lastMessageAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + synced: data.synced.present ? data.synced.value : this.synced, + ); + } + + @override + String toString() { + return (StringBuffer('Session(') + ..write('id: $id, ') + ..write('agentType: $agentType, ') + ..write('agentId: $agentId, ') + ..write('title: $title, ') + ..write('workingDirectory: $workingDirectory, ') + ..write('branch: $branch, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('synced: $synced') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + agentType, + agentId, + title, + workingDirectory, + branch, + status, + createdAt, + lastMessageAt, + updatedAt, + synced); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Session && + other.id == this.id && + other.agentType == this.agentType && + other.agentId == this.agentId && + other.title == this.title && + other.workingDirectory == this.workingDirectory && + other.branch == this.branch && + other.status == this.status && + other.createdAt == this.createdAt && + other.lastMessageAt == this.lastMessageAt && + other.updatedAt == this.updatedAt && + other.synced == this.synced); +} + +class SessionsCompanion extends UpdateCompanion { + final Value id; + final Value agentType; + final Value agentId; + final Value title; + final Value workingDirectory; + final Value branch; + final Value status; + final Value createdAt; + final Value lastMessageAt; + final Value updatedAt; + final Value synced; + final Value rowid; + const SessionsCompanion({ + this.id = const Value.absent(), + this.agentType = const Value.absent(), + this.agentId = const Value.absent(), + this.title = const Value.absent(), + this.workingDirectory = const Value.absent(), + this.branch = const Value.absent(), + this.status = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastMessageAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }); + SessionsCompanion.insert({ + required String id, + required String agentType, + this.agentId = const Value.absent(), + this.title = const Value.absent(), + required String workingDirectory, + this.branch = const Value.absent(), + required String status, + required DateTime createdAt, + this.lastMessageAt = const Value.absent(), + required DateTime updatedAt, + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + agentType = Value(agentType), + workingDirectory = Value(workingDirectory), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? agentType, + Expression? agentId, + Expression? title, + Expression? workingDirectory, + Expression? branch, + Expression? status, + Expression? createdAt, + Expression? lastMessageAt, + Expression? updatedAt, + Expression? synced, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (agentType != null) 'agent_type': agentType, + if (agentId != null) 'agent_id': agentId, + if (title != null) 'title': title, + if (workingDirectory != null) 'working_directory': workingDirectory, + if (branch != null) 'branch': branch, + if (status != null) 'status': status, + if (createdAt != null) 'created_at': createdAt, + if (lastMessageAt != null) 'last_message_at': lastMessageAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (synced != null) 'synced': synced, + if (rowid != null) 'rowid': rowid, + }); + } + + SessionsCompanion copyWith( + {Value? id, + Value? agentType, + Value? agentId, + Value? title, + Value? workingDirectory, + Value? branch, + Value? status, + Value? createdAt, + Value? lastMessageAt, + Value? updatedAt, + Value? synced, + Value? rowid}) { + return SessionsCompanion( + id: id ?? this.id, + agentType: agentType ?? this.agentType, + agentId: agentId ?? this.agentId, + title: title ?? this.title, + workingDirectory: workingDirectory ?? this.workingDirectory, + branch: branch ?? this.branch, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + updatedAt: updatedAt ?? this.updatedAt, + synced: synced ?? this.synced, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (agentType.present) { + map['agent_type'] = Variable(agentType.value); + } + if (agentId.present) { + map['agent_id'] = Variable(agentId.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (workingDirectory.present) { + map['working_directory'] = Variable(workingDirectory.value); + } + if (branch.present) { + map['branch'] = Variable(branch.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastMessageAt.present) { + map['last_message_at'] = Variable(lastMessageAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (synced.present) { + map['synced'] = Variable(synced.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SessionsCompanion(') + ..write('id: $id, ') + ..write('agentType: $agentType, ') + ..write('agentId: $agentId, ') + ..write('title: $title, ') + ..write('workingDirectory: $workingDirectory, ') + ..write('branch: $branch, ') + ..write('status: $status, ') + ..write('createdAt: $createdAt, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('synced: $synced, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sessionIdMeta = + const VerificationMeta('sessionId'); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES sessions (id)')); + static const VerificationMeta _roleMeta = const VerificationMeta('role'); + @override + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _contentMeta = + const VerificationMeta('content'); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _messageTypeMeta = + const VerificationMeta('messageType'); + @override + late final GeneratedColumn messageType = GeneratedColumn( + 'message_type', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('text')); + static const VerificationMeta _metadataMeta = + const VerificationMeta('metadata'); + @override + late final GeneratedColumn metadata = GeneratedColumn( + 'metadata', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _updatedAtMeta = + const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _syncedMeta = const VerificationMeta('synced'); + @override + late final GeneratedColumn synced = GeneratedColumn( + 'synced', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("synced" IN (0, 1))'), + defaultValue: const Constant(true)); + @override + List get $columns => [ + id, + sessionId, + role, + content, + messageType, + metadata, + createdAt, + updatedAt, + synced + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'messages'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('session_id')) { + context.handle(_sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta)); + } else if (isInserting) { + context.missing(_sessionIdMeta); + } + if (data.containsKey('role')) { + context.handle( + _roleMeta, role.isAcceptableOrUnknown(data['role']!, _roleMeta)); + } else if (isInserting) { + context.missing(_roleMeta); + } + if (data.containsKey('content')) { + context.handle(_contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta)); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('message_type')) { + context.handle( + _messageTypeMeta, + messageType.isAcceptableOrUnknown( + data['message_type']!, _messageTypeMeta)); + } + if (data.containsKey('metadata')) { + context.handle(_metadataMeta, + metadata.isAcceptableOrUnknown(data['metadata']!, _metadataMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('synced')) { + context.handle(_syncedMeta, + synced.isAcceptableOrUnknown(data['synced']!, _syncedMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Message map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Message( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + sessionId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}session_id'])!, + role: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}role'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}content'])!, + messageType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}message_type'])!, + metadata: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}metadata']), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + synced: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}synced'])!, + ); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(attachedDatabase, alias); + } +} + +class Message extends DataClass implements Insertable { + final String id; + final String sessionId; + + /// "user" | "agent" | "system" + final String role; + + /// Full message text (markdown). + final String content; + + /// "text" | "tool_call" | "tool_result" | "system" + final String messageType; + + /// JSON: token count, tool info, etc. + final String? metadata; + final DateTime createdAt; + final DateTime updatedAt; + final bool synced; + const Message( + {required this.id, + required this.sessionId, + required this.role, + required this.content, + required this.messageType, + this.metadata, + required this.createdAt, + required this.updatedAt, + required this.synced}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['session_id'] = Variable(sessionId); + map['role'] = Variable(role); + map['content'] = Variable(content); + map['message_type'] = Variable(messageType); + if (!nullToAbsent || metadata != null) { + map['metadata'] = Variable(metadata); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['synced'] = Variable(synced); + return map; + } + + MessagesCompanion toCompanion(bool nullToAbsent) { + return MessagesCompanion( + id: Value(id), + sessionId: Value(sessionId), + role: Value(role), + content: Value(content), + messageType: Value(messageType), + metadata: metadata == null && nullToAbsent + ? const Value.absent() + : Value(metadata), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + synced: Value(synced), + ); + } + + factory Message.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Message( + id: serializer.fromJson(json['id']), + sessionId: serializer.fromJson(json['sessionId']), + role: serializer.fromJson(json['role']), + content: serializer.fromJson(json['content']), + messageType: serializer.fromJson(json['messageType']), + metadata: serializer.fromJson(json['metadata']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + synced: serializer.fromJson(json['synced']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sessionId': serializer.toJson(sessionId), + 'role': serializer.toJson(role), + 'content': serializer.toJson(content), + 'messageType': serializer.toJson(messageType), + 'metadata': serializer.toJson(metadata), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'synced': serializer.toJson(synced), + }; + } + + Message copyWith( + {String? id, + String? sessionId, + String? role, + String? content, + String? messageType, + Value metadata = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + bool? synced}) => + Message( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + role: role ?? this.role, + content: content ?? this.content, + messageType: messageType ?? this.messageType, + metadata: metadata.present ? metadata.value : this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + synced: synced ?? this.synced, + ); + Message copyWithCompanion(MessagesCompanion data) { + return Message( + id: data.id.present ? data.id.value : this.id, + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + role: data.role.present ? data.role.value : this.role, + content: data.content.present ? data.content.value : this.content, + messageType: + data.messageType.present ? data.messageType.value : this.messageType, + metadata: data.metadata.present ? data.metadata.value : this.metadata, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + synced: data.synced.present ? data.synced.value : this.synced, + ); + } + + @override + String toString() { + return (StringBuffer('Message(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('role: $role, ') + ..write('content: $content, ') + ..write('messageType: $messageType, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('synced: $synced') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, sessionId, role, content, messageType, + metadata, createdAt, updatedAt, synced); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Message && + other.id == this.id && + other.sessionId == this.sessionId && + other.role == this.role && + other.content == this.content && + other.messageType == this.messageType && + other.metadata == this.metadata && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.synced == this.synced); +} + +class MessagesCompanion extends UpdateCompanion { + final Value id; + final Value sessionId; + final Value role; + final Value content; + final Value messageType; + final Value metadata; + final Value createdAt; + final Value updatedAt; + final Value synced; + final Value rowid; + const MessagesCompanion({ + this.id = const Value.absent(), + this.sessionId = const Value.absent(), + this.role = const Value.absent(), + this.content = const Value.absent(), + this.messageType = const Value.absent(), + this.metadata = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }); + MessagesCompanion.insert({ + required String id, + required String sessionId, + required String role, + required String content, + this.messageType = const Value.absent(), + this.metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + sessionId = Value(sessionId), + role = Value(role), + content = Value(content), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? sessionId, + Expression? role, + Expression? content, + Expression? messageType, + Expression? metadata, + Expression? createdAt, + Expression? updatedAt, + Expression? synced, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (sessionId != null) 'session_id': sessionId, + if (role != null) 'role': role, + if (content != null) 'content': content, + if (messageType != null) 'message_type': messageType, + if (metadata != null) 'metadata': metadata, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (synced != null) 'synced': synced, + if (rowid != null) 'rowid': rowid, + }); + } + + MessagesCompanion copyWith( + {Value? id, + Value? sessionId, + Value? role, + Value? content, + Value? messageType, + Value? metadata, + Value? createdAt, + Value? updatedAt, + Value? synced, + Value? rowid}) { + return MessagesCompanion( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + role: role ?? this.role, + content: content ?? this.content, + messageType: messageType ?? this.messageType, + metadata: metadata ?? this.metadata, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + synced: synced ?? this.synced, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (messageType.present) { + map['message_type'] = Variable(messageType.value); + } + if (metadata.present) { + map['metadata'] = Variable(metadata.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (synced.present) { + map['synced'] = Variable(synced.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('role: $role, ') + ..write('content: $content, ') + ..write('messageType: $messageType, ') + ..write('metadata: $metadata, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('synced: $synced, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $AgentsTable extends Agents with TableInfo<$AgentsTable, Agent> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AgentsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _displayNameMeta = + const VerificationMeta('displayName'); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _agentTypeMeta = + const VerificationMeta('agentType'); + @override + late final GeneratedColumn agentType = GeneratedColumn( + 'agent_type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _bridgeUrlMeta = + const VerificationMeta('bridgeUrl'); + @override + late final GeneratedColumn bridgeUrl = GeneratedColumn( + 'bridge_url', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _authTokenMeta = + const VerificationMeta('authToken'); + @override + late final GeneratedColumn authToken = GeneratedColumn( + 'auth_token', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _workingDirectoryMeta = + const VerificationMeta('workingDirectory'); + @override + late final GeneratedColumn workingDirectory = GeneratedColumn( + 'working_directory', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('disconnected')); + static const VerificationMeta _lastConnectedAtMeta = + const VerificationMeta('lastConnectedAt'); + @override + late final GeneratedColumn lastConnectedAt = + GeneratedColumn('last_connected_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _updatedAtMeta = + const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [ + id, + displayName, + agentType, + bridgeUrl, + authToken, + workingDirectory, + status, + lastConnectedAt, + createdAt, + updatedAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'agents'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, _displayNameMeta)); + } else if (isInserting) { + context.missing(_displayNameMeta); + } + if (data.containsKey('agent_type')) { + context.handle(_agentTypeMeta, + agentType.isAcceptableOrUnknown(data['agent_type']!, _agentTypeMeta)); + } else if (isInserting) { + context.missing(_agentTypeMeta); + } + if (data.containsKey('bridge_url')) { + context.handle(_bridgeUrlMeta, + bridgeUrl.isAcceptableOrUnknown(data['bridge_url']!, _bridgeUrlMeta)); + } else if (isInserting) { + context.missing(_bridgeUrlMeta); + } + if (data.containsKey('auth_token')) { + context.handle(_authTokenMeta, + authToken.isAcceptableOrUnknown(data['auth_token']!, _authTokenMeta)); + } else if (isInserting) { + context.missing(_authTokenMeta); + } + if (data.containsKey('working_directory')) { + context.handle( + _workingDirectoryMeta, + workingDirectory.isAcceptableOrUnknown( + data['working_directory']!, _workingDirectoryMeta)); + } + if (data.containsKey('status')) { + context.handle(_statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta)); + } + if (data.containsKey('last_connected_at')) { + context.handle( + _lastConnectedAtMeta, + lastConnectedAt.isAcceptableOrUnknown( + data['last_connected_at']!, _lastConnectedAtMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Agent map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Agent( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + displayName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}display_name'])!, + agentType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}agent_type'])!, + bridgeUrl: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}bridge_url'])!, + authToken: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}auth_token'])!, + workingDirectory: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}working_directory']), + status: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}status'])!, + lastConnectedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_connected_at']), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ); + } + + @override + $AgentsTable createAlias(String alias) { + return $AgentsTable(attachedDatabase, alias); + } +} + +class Agent extends DataClass implements Insertable { + final String id; + final String displayName; + + /// "claude-code" | "opencode" | "aider" | "goose" | "custom" + final String agentType; + + /// WebSocket bridge URL, e.g. "wss://100.78.42.15:3000" + final String bridgeUrl; + + /// Encrypted bridge auth token. + final String authToken; + final String? workingDirectory; + + /// "connected" | "disconnected" | "inactive" + final String status; + final DateTime? lastConnectedAt; + final DateTime createdAt; + final DateTime updatedAt; + const Agent( + {required this.id, + required this.displayName, + required this.agentType, + required this.bridgeUrl, + required this.authToken, + this.workingDirectory, + required this.status, + this.lastConnectedAt, + required this.createdAt, + required this.updatedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['display_name'] = Variable(displayName); + map['agent_type'] = Variable(agentType); + map['bridge_url'] = Variable(bridgeUrl); + map['auth_token'] = Variable(authToken); + if (!nullToAbsent || workingDirectory != null) { + map['working_directory'] = Variable(workingDirectory); + } + map['status'] = Variable(status); + if (!nullToAbsent || lastConnectedAt != null) { + map['last_connected_at'] = Variable(lastConnectedAt); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + AgentsCompanion toCompanion(bool nullToAbsent) { + return AgentsCompanion( + id: Value(id), + displayName: Value(displayName), + agentType: Value(agentType), + bridgeUrl: Value(bridgeUrl), + authToken: Value(authToken), + workingDirectory: workingDirectory == null && nullToAbsent + ? const Value.absent() + : Value(workingDirectory), + status: Value(status), + lastConnectedAt: lastConnectedAt == null && nullToAbsent + ? const Value.absent() + : Value(lastConnectedAt), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory Agent.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Agent( + id: serializer.fromJson(json['id']), + displayName: serializer.fromJson(json['displayName']), + agentType: serializer.fromJson(json['agentType']), + bridgeUrl: serializer.fromJson(json['bridgeUrl']), + authToken: serializer.fromJson(json['authToken']), + workingDirectory: serializer.fromJson(json['workingDirectory']), + status: serializer.fromJson(json['status']), + lastConnectedAt: serializer.fromJson(json['lastConnectedAt']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'displayName': serializer.toJson(displayName), + 'agentType': serializer.toJson(agentType), + 'bridgeUrl': serializer.toJson(bridgeUrl), + 'authToken': serializer.toJson(authToken), + 'workingDirectory': serializer.toJson(workingDirectory), + 'status': serializer.toJson(status), + 'lastConnectedAt': serializer.toJson(lastConnectedAt), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + Agent copyWith( + {String? id, + String? displayName, + String? agentType, + String? bridgeUrl, + String? authToken, + Value workingDirectory = const Value.absent(), + String? status, + Value lastConnectedAt = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt}) => + Agent( + id: id ?? this.id, + displayName: displayName ?? this.displayName, + agentType: agentType ?? this.agentType, + bridgeUrl: bridgeUrl ?? this.bridgeUrl, + authToken: authToken ?? this.authToken, + workingDirectory: workingDirectory.present + ? workingDirectory.value + : this.workingDirectory, + status: status ?? this.status, + lastConnectedAt: lastConnectedAt.present + ? lastConnectedAt.value + : this.lastConnectedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + Agent copyWithCompanion(AgentsCompanion data) { + return Agent( + id: data.id.present ? data.id.value : this.id, + displayName: + data.displayName.present ? data.displayName.value : this.displayName, + agentType: data.agentType.present ? data.agentType.value : this.agentType, + bridgeUrl: data.bridgeUrl.present ? data.bridgeUrl.value : this.bridgeUrl, + authToken: data.authToken.present ? data.authToken.value : this.authToken, + workingDirectory: data.workingDirectory.present + ? data.workingDirectory.value + : this.workingDirectory, + status: data.status.present ? data.status.value : this.status, + lastConnectedAt: data.lastConnectedAt.present + ? data.lastConnectedAt.value + : this.lastConnectedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('Agent(') + ..write('id: $id, ') + ..write('displayName: $displayName, ') + ..write('agentType: $agentType, ') + ..write('bridgeUrl: $bridgeUrl, ') + ..write('authToken: $authToken, ') + ..write('workingDirectory: $workingDirectory, ') + ..write('status: $status, ') + ..write('lastConnectedAt: $lastConnectedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + displayName, + agentType, + bridgeUrl, + authToken, + workingDirectory, + status, + lastConnectedAt, + createdAt, + updatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Agent && + other.id == this.id && + other.displayName == this.displayName && + other.agentType == this.agentType && + other.bridgeUrl == this.bridgeUrl && + other.authToken == this.authToken && + other.workingDirectory == this.workingDirectory && + other.status == this.status && + other.lastConnectedAt == this.lastConnectedAt && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class AgentsCompanion extends UpdateCompanion { + final Value id; + final Value displayName; + final Value agentType; + final Value bridgeUrl; + final Value authToken; + final Value workingDirectory; + final Value status; + final Value lastConnectedAt; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const AgentsCompanion({ + this.id = const Value.absent(), + this.displayName = const Value.absent(), + this.agentType = const Value.absent(), + this.bridgeUrl = const Value.absent(), + this.authToken = const Value.absent(), + this.workingDirectory = const Value.absent(), + this.status = const Value.absent(), + this.lastConnectedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + AgentsCompanion.insert({ + required String id, + required String displayName, + required String agentType, + required String bridgeUrl, + required String authToken, + this.workingDirectory = const Value.absent(), + this.status = const Value.absent(), + this.lastConnectedAt = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + displayName = Value(displayName), + agentType = Value(agentType), + bridgeUrl = Value(bridgeUrl), + authToken = Value(authToken), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? displayName, + Expression? agentType, + Expression? bridgeUrl, + Expression? authToken, + Expression? workingDirectory, + Expression? status, + Expression? lastConnectedAt, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (displayName != null) 'display_name': displayName, + if (agentType != null) 'agent_type': agentType, + if (bridgeUrl != null) 'bridge_url': bridgeUrl, + if (authToken != null) 'auth_token': authToken, + if (workingDirectory != null) 'working_directory': workingDirectory, + if (status != null) 'status': status, + if (lastConnectedAt != null) 'last_connected_at': lastConnectedAt, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + AgentsCompanion copyWith( + {Value? id, + Value? displayName, + Value? agentType, + Value? bridgeUrl, + Value? authToken, + Value? workingDirectory, + Value? status, + Value? lastConnectedAt, + Value? createdAt, + Value? updatedAt, + Value? rowid}) { + return AgentsCompanion( + id: id ?? this.id, + displayName: displayName ?? this.displayName, + agentType: agentType ?? this.agentType, + bridgeUrl: bridgeUrl ?? this.bridgeUrl, + authToken: authToken ?? this.authToken, + workingDirectory: workingDirectory ?? this.workingDirectory, + status: status ?? this.status, + lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + if (agentType.present) { + map['agent_type'] = Variable(agentType.value); + } + if (bridgeUrl.present) { + map['bridge_url'] = Variable(bridgeUrl.value); + } + if (authToken.present) { + map['auth_token'] = Variable(authToken.value); + } + if (workingDirectory.present) { + map['working_directory'] = Variable(workingDirectory.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (lastConnectedAt.present) { + map['last_connected_at'] = Variable(lastConnectedAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AgentsCompanion(') + ..write('id: $id, ') + ..write('displayName: $displayName, ') + ..write('agentType: $agentType, ') + ..write('bridgeUrl: $bridgeUrl, ') + ..write('authToken: $authToken, ') + ..write('workingDirectory: $workingDirectory, ') + ..write('status: $status, ') + ..write('lastConnectedAt: $lastConnectedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $ApprovalsTable extends Approvals + with TableInfo<$ApprovalsTable, Approval> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ApprovalsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sessionIdMeta = + const VerificationMeta('sessionId'); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES sessions (id)')); + static const VerificationMeta _toolMeta = const VerificationMeta('tool'); + @override + late final GeneratedColumn tool = GeneratedColumn( + 'tool', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _descriptionMeta = + const VerificationMeta('description'); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _paramsMeta = const VerificationMeta('params'); + @override + late final GeneratedColumn params = GeneratedColumn( + 'params', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _reasoningMeta = + const VerificationMeta('reasoning'); + @override + late final GeneratedColumn reasoning = GeneratedColumn( + 'reasoning', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _riskLevelMeta = + const VerificationMeta('riskLevel'); + @override + late final GeneratedColumn riskLevel = GeneratedColumn( + 'risk_level', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _decisionMeta = + const VerificationMeta('decision'); + @override + late final GeneratedColumn decision = GeneratedColumn( + 'decision', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _modificationsMeta = + const VerificationMeta('modifications'); + @override + late final GeneratedColumn modifications = GeneratedColumn( + 'modifications', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _resultMeta = const VerificationMeta('result'); + @override + late final GeneratedColumn result = GeneratedColumn( + 'result', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _decidedAtMeta = + const VerificationMeta('decidedAt'); + @override + late final GeneratedColumn decidedAt = GeneratedColumn( + 'decided_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _syncedMeta = const VerificationMeta('synced'); + @override + late final GeneratedColumn synced = GeneratedColumn( + 'synced', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("synced" IN (0, 1))'), + defaultValue: const Constant(true)); + @override + List get $columns => [ + id, + sessionId, + tool, + description, + params, + reasoning, + riskLevel, + decision, + modifications, + result, + createdAt, + decidedAt, + synced + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'approvals'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('session_id')) { + context.handle(_sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta)); + } else if (isInserting) { + context.missing(_sessionIdMeta); + } + if (data.containsKey('tool')) { + context.handle( + _toolMeta, tool.isAcceptableOrUnknown(data['tool']!, _toolMeta)); + } else if (isInserting) { + context.missing(_toolMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, _descriptionMeta)); + } else if (isInserting) { + context.missing(_descriptionMeta); + } + if (data.containsKey('params')) { + context.handle(_paramsMeta, + params.isAcceptableOrUnknown(data['params']!, _paramsMeta)); + } else if (isInserting) { + context.missing(_paramsMeta); + } + if (data.containsKey('reasoning')) { + context.handle(_reasoningMeta, + reasoning.isAcceptableOrUnknown(data['reasoning']!, _reasoningMeta)); + } + if (data.containsKey('risk_level')) { + context.handle(_riskLevelMeta, + riskLevel.isAcceptableOrUnknown(data['risk_level']!, _riskLevelMeta)); + } else if (isInserting) { + context.missing(_riskLevelMeta); + } + if (data.containsKey('decision')) { + context.handle(_decisionMeta, + decision.isAcceptableOrUnknown(data['decision']!, _decisionMeta)); + } else if (isInserting) { + context.missing(_decisionMeta); + } + if (data.containsKey('modifications')) { + context.handle( + _modificationsMeta, + modifications.isAcceptableOrUnknown( + data['modifications']!, _modificationsMeta)); + } + if (data.containsKey('result')) { + context.handle(_resultMeta, + result.isAcceptableOrUnknown(data['result']!, _resultMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('decided_at')) { + context.handle(_decidedAtMeta, + decidedAt.isAcceptableOrUnknown(data['decided_at']!, _decidedAtMeta)); + } + if (data.containsKey('synced')) { + context.handle(_syncedMeta, + synced.isAcceptableOrUnknown(data['synced']!, _syncedMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Approval map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Approval( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + sessionId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}session_id'])!, + tool: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}tool'])!, + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + params: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}params'])!, + reasoning: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}reasoning']), + riskLevel: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}risk_level'])!, + decision: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}decision'])!, + modifications: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}modifications']), + result: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}result']), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + decidedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}decided_at']), + synced: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}synced'])!, + ); + } + + @override + $ApprovalsTable createAlias(String alias) { + return $ApprovalsTable(attachedDatabase, alias); + } +} + +class Approval extends DataClass implements Insertable { + final String id; + final String sessionId; + final String tool; + final String description; + + /// JSON: tool parameters. + final String params; + + /// Agent's explanation. + final String? reasoning; + + /// "low" | "medium" | "high" | "critical" + final String riskLevel; + + /// "approved" | "rejected" | "modified" | "pending" + final String decision; + + /// User's modification instructions. + final String? modifications; + + /// JSON: tool execution result. + final String? result; + final DateTime createdAt; + final DateTime? decidedAt; + final bool synced; + const Approval( + {required this.id, + required this.sessionId, + required this.tool, + required this.description, + required this.params, + this.reasoning, + required this.riskLevel, + required this.decision, + this.modifications, + this.result, + required this.createdAt, + this.decidedAt, + required this.synced}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['session_id'] = Variable(sessionId); + map['tool'] = Variable(tool); + map['description'] = Variable(description); + map['params'] = Variable(params); + if (!nullToAbsent || reasoning != null) { + map['reasoning'] = Variable(reasoning); + } + map['risk_level'] = Variable(riskLevel); + map['decision'] = Variable(decision); + if (!nullToAbsent || modifications != null) { + map['modifications'] = Variable(modifications); + } + if (!nullToAbsent || result != null) { + map['result'] = Variable(result); + } + map['created_at'] = Variable(createdAt); + if (!nullToAbsent || decidedAt != null) { + map['decided_at'] = Variable(decidedAt); + } + map['synced'] = Variable(synced); + return map; + } + + ApprovalsCompanion toCompanion(bool nullToAbsent) { + return ApprovalsCompanion( + id: Value(id), + sessionId: Value(sessionId), + tool: Value(tool), + description: Value(description), + params: Value(params), + reasoning: reasoning == null && nullToAbsent + ? const Value.absent() + : Value(reasoning), + riskLevel: Value(riskLevel), + decision: Value(decision), + modifications: modifications == null && nullToAbsent + ? const Value.absent() + : Value(modifications), + result: + result == null && nullToAbsent ? const Value.absent() : Value(result), + createdAt: Value(createdAt), + decidedAt: decidedAt == null && nullToAbsent + ? const Value.absent() + : Value(decidedAt), + synced: Value(synced), + ); + } + + factory Approval.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Approval( + id: serializer.fromJson(json['id']), + sessionId: serializer.fromJson(json['sessionId']), + tool: serializer.fromJson(json['tool']), + description: serializer.fromJson(json['description']), + params: serializer.fromJson(json['params']), + reasoning: serializer.fromJson(json['reasoning']), + riskLevel: serializer.fromJson(json['riskLevel']), + decision: serializer.fromJson(json['decision']), + modifications: serializer.fromJson(json['modifications']), + result: serializer.fromJson(json['result']), + createdAt: serializer.fromJson(json['createdAt']), + decidedAt: serializer.fromJson(json['decidedAt']), + synced: serializer.fromJson(json['synced']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'sessionId': serializer.toJson(sessionId), + 'tool': serializer.toJson(tool), + 'description': serializer.toJson(description), + 'params': serializer.toJson(params), + 'reasoning': serializer.toJson(reasoning), + 'riskLevel': serializer.toJson(riskLevel), + 'decision': serializer.toJson(decision), + 'modifications': serializer.toJson(modifications), + 'result': serializer.toJson(result), + 'createdAt': serializer.toJson(createdAt), + 'decidedAt': serializer.toJson(decidedAt), + 'synced': serializer.toJson(synced), + }; + } + + Approval copyWith( + {String? id, + String? sessionId, + String? tool, + String? description, + String? params, + Value reasoning = const Value.absent(), + String? riskLevel, + String? decision, + Value modifications = const Value.absent(), + Value result = const Value.absent(), + DateTime? createdAt, + Value decidedAt = const Value.absent(), + bool? synced}) => + Approval( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + tool: tool ?? this.tool, + description: description ?? this.description, + params: params ?? this.params, + reasoning: reasoning.present ? reasoning.value : this.reasoning, + riskLevel: riskLevel ?? this.riskLevel, + decision: decision ?? this.decision, + modifications: + modifications.present ? modifications.value : this.modifications, + result: result.present ? result.value : this.result, + createdAt: createdAt ?? this.createdAt, + decidedAt: decidedAt.present ? decidedAt.value : this.decidedAt, + synced: synced ?? this.synced, + ); + Approval copyWithCompanion(ApprovalsCompanion data) { + return Approval( + id: data.id.present ? data.id.value : this.id, + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + tool: data.tool.present ? data.tool.value : this.tool, + description: + data.description.present ? data.description.value : this.description, + params: data.params.present ? data.params.value : this.params, + reasoning: data.reasoning.present ? data.reasoning.value : this.reasoning, + riskLevel: data.riskLevel.present ? data.riskLevel.value : this.riskLevel, + decision: data.decision.present ? data.decision.value : this.decision, + modifications: data.modifications.present + ? data.modifications.value + : this.modifications, + result: data.result.present ? data.result.value : this.result, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + decidedAt: data.decidedAt.present ? data.decidedAt.value : this.decidedAt, + synced: data.synced.present ? data.synced.value : this.synced, + ); + } + + @override + String toString() { + return (StringBuffer('Approval(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('tool: $tool, ') + ..write('description: $description, ') + ..write('params: $params, ') + ..write('reasoning: $reasoning, ') + ..write('riskLevel: $riskLevel, ') + ..write('decision: $decision, ') + ..write('modifications: $modifications, ') + ..write('result: $result, ') + ..write('createdAt: $createdAt, ') + ..write('decidedAt: $decidedAt, ') + ..write('synced: $synced') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + sessionId, + tool, + description, + params, + reasoning, + riskLevel, + decision, + modifications, + result, + createdAt, + decidedAt, + synced); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Approval && + other.id == this.id && + other.sessionId == this.sessionId && + other.tool == this.tool && + other.description == this.description && + other.params == this.params && + other.reasoning == this.reasoning && + other.riskLevel == this.riskLevel && + other.decision == this.decision && + other.modifications == this.modifications && + other.result == this.result && + other.createdAt == this.createdAt && + other.decidedAt == this.decidedAt && + other.synced == this.synced); +} + +class ApprovalsCompanion extends UpdateCompanion { + final Value id; + final Value sessionId; + final Value tool; + final Value description; + final Value params; + final Value reasoning; + final Value riskLevel; + final Value decision; + final Value modifications; + final Value result; + final Value createdAt; + final Value decidedAt; + final Value synced; + final Value rowid; + const ApprovalsCompanion({ + this.id = const Value.absent(), + this.sessionId = const Value.absent(), + this.tool = const Value.absent(), + this.description = const Value.absent(), + this.params = const Value.absent(), + this.reasoning = const Value.absent(), + this.riskLevel = const Value.absent(), + this.decision = const Value.absent(), + this.modifications = const Value.absent(), + this.result = const Value.absent(), + this.createdAt = const Value.absent(), + this.decidedAt = const Value.absent(), + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }); + ApprovalsCompanion.insert({ + required String id, + required String sessionId, + required String tool, + required String description, + required String params, + this.reasoning = const Value.absent(), + required String riskLevel, + required String decision, + this.modifications = const Value.absent(), + this.result = const Value.absent(), + required DateTime createdAt, + this.decidedAt = const Value.absent(), + this.synced = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + sessionId = Value(sessionId), + tool = Value(tool), + description = Value(description), + params = Value(params), + riskLevel = Value(riskLevel), + decision = Value(decision), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? sessionId, + Expression? tool, + Expression? description, + Expression? params, + Expression? reasoning, + Expression? riskLevel, + Expression? decision, + Expression? modifications, + Expression? result, + Expression? createdAt, + Expression? decidedAt, + Expression? synced, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (sessionId != null) 'session_id': sessionId, + if (tool != null) 'tool': tool, + if (description != null) 'description': description, + if (params != null) 'params': params, + if (reasoning != null) 'reasoning': reasoning, + if (riskLevel != null) 'risk_level': riskLevel, + if (decision != null) 'decision': decision, + if (modifications != null) 'modifications': modifications, + if (result != null) 'result': result, + if (createdAt != null) 'created_at': createdAt, + if (decidedAt != null) 'decided_at': decidedAt, + if (synced != null) 'synced': synced, + if (rowid != null) 'rowid': rowid, + }); + } + + ApprovalsCompanion copyWith( + {Value? id, + Value? sessionId, + Value? tool, + Value? description, + Value? params, + Value? reasoning, + Value? riskLevel, + Value? decision, + Value? modifications, + Value? result, + Value? createdAt, + Value? decidedAt, + Value? synced, + Value? rowid}) { + return ApprovalsCompanion( + id: id ?? this.id, + sessionId: sessionId ?? this.sessionId, + tool: tool ?? this.tool, + description: description ?? this.description, + params: params ?? this.params, + reasoning: reasoning ?? this.reasoning, + riskLevel: riskLevel ?? this.riskLevel, + decision: decision ?? this.decision, + modifications: modifications ?? this.modifications, + result: result ?? this.result, + createdAt: createdAt ?? this.createdAt, + decidedAt: decidedAt ?? this.decidedAt, + synced: synced ?? this.synced, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (tool.present) { + map['tool'] = Variable(tool.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (params.present) { + map['params'] = Variable(params.value); + } + if (reasoning.present) { + map['reasoning'] = Variable(reasoning.value); + } + if (riskLevel.present) { + map['risk_level'] = Variable(riskLevel.value); + } + if (decision.present) { + map['decision'] = Variable(decision.value); + } + if (modifications.present) { + map['modifications'] = Variable(modifications.value); + } + if (result.present) { + map['result'] = Variable(result.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (decidedAt.present) { + map['decided_at'] = Variable(decidedAt.value); + } + if (synced.present) { + map['synced'] = Variable(synced.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ApprovalsCompanion(') + ..write('id: $id, ') + ..write('sessionId: $sessionId, ') + ..write('tool: $tool, ') + ..write('description: $description, ') + ..write('params: $params, ') + ..write('reasoning: $reasoning, ') + ..write('riskLevel: $riskLevel, ') + ..write('decision: $decision, ') + ..write('modifications: $modifications, ') + ..write('result: $result, ') + ..write('createdAt: $createdAt, ') + ..write('decidedAt: $decidedAt, ') + ..write('synced: $synced, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SyncQueueTable extends SyncQueue + with TableInfo<$SyncQueueTable, SyncQueueData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncQueueTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _operationMeta = + const VerificationMeta('operation'); + @override + late final GeneratedColumn operation = GeneratedColumn( + 'operation', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _payloadMeta = + const VerificationMeta('payload'); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sessionIdMeta = + const VerificationMeta('sessionId'); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _syncedMeta = const VerificationMeta('synced'); + @override + late final GeneratedColumn synced = GeneratedColumn( + 'synced', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("synced" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _retryCountMeta = + const VerificationMeta('retryCount'); + @override + late final GeneratedColumn retryCount = GeneratedColumn( + 'retry_count', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0)); + static const VerificationMeta _lastErrorMeta = + const VerificationMeta('lastError'); + @override + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + id, + operation, + payload, + sessionId, + createdAt, + synced, + retryCount, + lastError + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_queue'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('operation')) { + context.handle(_operationMeta, + operation.isAcceptableOrUnknown(data['operation']!, _operationMeta)); + } else if (isInserting) { + context.missing(_operationMeta); + } + if (data.containsKey('payload')) { + context.handle(_payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta)); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('session_id')) { + context.handle(_sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('synced')) { + context.handle(_syncedMeta, + synced.isAcceptableOrUnknown(data['synced']!, _syncedMeta)); + } + if (data.containsKey('retry_count')) { + context.handle( + _retryCountMeta, + retryCount.isAcceptableOrUnknown( + data['retry_count']!, _retryCountMeta)); + } + if (data.containsKey('last_error')) { + context.handle(_lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SyncQueueData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncQueueData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + operation: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}operation'])!, + payload: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}payload'])!, + sessionId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}session_id']), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + synced: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}synced'])!, + retryCount: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}retry_count'])!, + lastError: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}last_error']), + ); + } + + @override + $SyncQueueTable createAlias(String alias) { + return $SyncQueueTable(attachedDatabase, alias); + } +} + +class SyncQueueData extends DataClass implements Insertable { + final int id; + + /// "send_message" | "approve_tool" | "git_command" + final String operation; + + /// JSON: full operation payload. + final String payload; + final String? sessionId; + final DateTime createdAt; + final bool synced; + final int retryCount; + final String? lastError; + const SyncQueueData( + {required this.id, + required this.operation, + required this.payload, + this.sessionId, + required this.createdAt, + required this.synced, + required this.retryCount, + this.lastError}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['operation'] = Variable(operation); + map['payload'] = Variable(payload); + if (!nullToAbsent || sessionId != null) { + map['session_id'] = Variable(sessionId); + } + map['created_at'] = Variable(createdAt); + map['synced'] = Variable(synced); + map['retry_count'] = Variable(retryCount); + if (!nullToAbsent || lastError != null) { + map['last_error'] = Variable(lastError); + } + return map; + } + + SyncQueueCompanion toCompanion(bool nullToAbsent) { + return SyncQueueCompanion( + id: Value(id), + operation: Value(operation), + payload: Value(payload), + sessionId: sessionId == null && nullToAbsent + ? const Value.absent() + : Value(sessionId), + createdAt: Value(createdAt), + synced: Value(synced), + retryCount: Value(retryCount), + lastError: lastError == null && nullToAbsent + ? const Value.absent() + : Value(lastError), + ); + } + + factory SyncQueueData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncQueueData( + id: serializer.fromJson(json['id']), + operation: serializer.fromJson(json['operation']), + payload: serializer.fromJson(json['payload']), + sessionId: serializer.fromJson(json['sessionId']), + createdAt: serializer.fromJson(json['createdAt']), + synced: serializer.fromJson(json['synced']), + retryCount: serializer.fromJson(json['retryCount']), + lastError: serializer.fromJson(json['lastError']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'operation': serializer.toJson(operation), + 'payload': serializer.toJson(payload), + 'sessionId': serializer.toJson(sessionId), + 'createdAt': serializer.toJson(createdAt), + 'synced': serializer.toJson(synced), + 'retryCount': serializer.toJson(retryCount), + 'lastError': serializer.toJson(lastError), + }; + } + + SyncQueueData copyWith( + {int? id, + String? operation, + String? payload, + Value sessionId = const Value.absent(), + DateTime? createdAt, + bool? synced, + int? retryCount, + Value lastError = const Value.absent()}) => + SyncQueueData( + id: id ?? this.id, + operation: operation ?? this.operation, + payload: payload ?? this.payload, + sessionId: sessionId.present ? sessionId.value : this.sessionId, + createdAt: createdAt ?? this.createdAt, + synced: synced ?? this.synced, + retryCount: retryCount ?? this.retryCount, + lastError: lastError.present ? lastError.value : this.lastError, + ); + SyncQueueData copyWithCompanion(SyncQueueCompanion data) { + return SyncQueueData( + id: data.id.present ? data.id.value : this.id, + operation: data.operation.present ? data.operation.value : this.operation, + payload: data.payload.present ? data.payload.value : this.payload, + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + synced: data.synced.present ? data.synced.value : this.synced, + retryCount: + data.retryCount.present ? data.retryCount.value : this.retryCount, + lastError: data.lastError.present ? data.lastError.value : this.lastError, + ); + } + + @override + String toString() { + return (StringBuffer('SyncQueueData(') + ..write('id: $id, ') + ..write('operation: $operation, ') + ..write('payload: $payload, ') + ..write('sessionId: $sessionId, ') + ..write('createdAt: $createdAt, ') + ..write('synced: $synced, ') + ..write('retryCount: $retryCount, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, operation, payload, sessionId, createdAt, + synced, retryCount, lastError); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncQueueData && + other.id == this.id && + other.operation == this.operation && + other.payload == this.payload && + other.sessionId == this.sessionId && + other.createdAt == this.createdAt && + other.synced == this.synced && + other.retryCount == this.retryCount && + other.lastError == this.lastError); +} + +class SyncQueueCompanion extends UpdateCompanion { + final Value id; + final Value operation; + final Value payload; + final Value sessionId; + final Value createdAt; + final Value synced; + final Value retryCount; + final Value lastError; + const SyncQueueCompanion({ + this.id = const Value.absent(), + this.operation = const Value.absent(), + this.payload = const Value.absent(), + this.sessionId = const Value.absent(), + this.createdAt = const Value.absent(), + this.synced = const Value.absent(), + this.retryCount = const Value.absent(), + this.lastError = const Value.absent(), + }); + SyncQueueCompanion.insert({ + this.id = const Value.absent(), + required String operation, + required String payload, + this.sessionId = const Value.absent(), + required DateTime createdAt, + this.synced = const Value.absent(), + this.retryCount = const Value.absent(), + this.lastError = const Value.absent(), + }) : operation = Value(operation), + payload = Value(payload), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? operation, + Expression? payload, + Expression? sessionId, + Expression? createdAt, + Expression? synced, + Expression? retryCount, + Expression? lastError, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (operation != null) 'operation': operation, + if (payload != null) 'payload': payload, + if (sessionId != null) 'session_id': sessionId, + if (createdAt != null) 'created_at': createdAt, + if (synced != null) 'synced': synced, + if (retryCount != null) 'retry_count': retryCount, + if (lastError != null) 'last_error': lastError, + }); + } + + SyncQueueCompanion copyWith( + {Value? id, + Value? operation, + Value? payload, + Value? sessionId, + Value? createdAt, + Value? synced, + Value? retryCount, + Value? lastError}) { + return SyncQueueCompanion( + id: id ?? this.id, + operation: operation ?? this.operation, + payload: payload ?? this.payload, + sessionId: sessionId ?? this.sessionId, + createdAt: createdAt ?? this.createdAt, + synced: synced ?? this.synced, + retryCount: retryCount ?? this.retryCount, + lastError: lastError ?? this.lastError, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (operation.present) { + map['operation'] = Variable(operation.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (synced.present) { + map['synced'] = Variable(synced.value); + } + if (retryCount.present) { + map['retry_count'] = Variable(retryCount.value); + } + if (lastError.present) { + map['last_error'] = Variable(lastError.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncQueueCompanion(') + ..write('id: $id, ') + ..write('operation: $operation, ') + ..write('payload: $payload, ') + ..write('sessionId: $sessionId, ') + ..write('createdAt: $createdAt, ') + ..write('synced: $synced, ') + ..write('retryCount: $retryCount, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $SessionsTable sessions = $SessionsTable(this); + late final $MessagesTable messages = $MessagesTable(this); + late final $AgentsTable agents = $AgentsTable(this); + late final $ApprovalsTable approvals = $ApprovalsTable(this); + late final $SyncQueueTable syncQueue = $SyncQueueTable(this); + late final SessionDao sessionDao = SessionDao(this as AppDatabase); + late final MessageDao messageDao = MessageDao(this as AppDatabase); + late final SyncDao syncDao = SyncDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [sessions, messages, agents, approvals, syncQueue]; +} + +typedef $$SessionsTableCreateCompanionBuilder = SessionsCompanion Function({ + required String id, + required String agentType, + Value agentId, + Value title, + required String workingDirectory, + Value branch, + required String status, + required DateTime createdAt, + Value lastMessageAt, + required DateTime updatedAt, + Value synced, + Value rowid, +}); +typedef $$SessionsTableUpdateCompanionBuilder = SessionsCompanion Function({ + Value id, + Value agentType, + Value agentId, + Value title, + Value workingDirectory, + Value branch, + Value status, + Value createdAt, + Value lastMessageAt, + Value updatedAt, + Value synced, + Value rowid, +}); + +final class $$SessionsTableReferences + extends BaseReferences<_$AppDatabase, $SessionsTable, Session> { + $$SessionsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$MessagesTable, List> _messagesRefsTable( + _$AppDatabase db) => + MultiTypedResultKey.fromTable(db.messages, + aliasName: + $_aliasNameGenerator(db.sessions.id, db.messages.sessionId)); + + $$MessagesTableProcessedTableManager get messagesRefs { + final manager = $$MessagesTableTableManager($_db, $_db.messages) + .filter((f) => f.sessionId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_messagesRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } + + static MultiTypedResultKey<$ApprovalsTable, List> + _approvalsRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable(db.approvals, + aliasName: + $_aliasNameGenerator(db.sessions.id, db.approvals.sessionId)); + + $$ApprovalsTableProcessedTableManager get approvalsRefs { + final manager = $$ApprovalsTableTableManager($_db, $_db.approvals) + .filter((f) => f.sessionId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_approvalsRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$SessionsTableFilterComposer + extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get agentType => $composableBuilder( + column: $table.agentType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get agentId => $composableBuilder( + column: $table.agentId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnFilters(column)); + + ColumnFilters get workingDirectory => $composableBuilder( + column: $table.workingDirectory, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get branch => $composableBuilder( + column: $table.branch, builder: (column) => ColumnFilters(column)); + + ColumnFilters get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastMessageAt => $composableBuilder( + column: $table.lastMessageAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnFilters(column)); + + Expression messagesRefs( + Expression Function($$MessagesTableFilterComposer f) f) { + final $$MessagesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.sessionId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } + + Expression approvalsRefs( + Expression Function($$ApprovalsTableFilterComposer f) f) { + final $$ApprovalsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.approvals, + getReferencedColumn: (t) => t.sessionId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ApprovalsTableFilterComposer( + $db: $db, + $table: $db.approvals, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$SessionsTableOrderingComposer + extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get agentType => $composableBuilder( + column: $table.agentType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get agentId => $composableBuilder( + column: $table.agentId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get workingDirectory => $composableBuilder( + column: $table.workingDirectory, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get branch => $composableBuilder( + column: $table.branch, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastMessageAt => $composableBuilder( + column: $table.lastMessageAt, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnOrderings(column)); +} + +class $$SessionsTableAnnotationComposer + extends Composer<_$AppDatabase, $SessionsTable> { + $$SessionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get agentType => + $composableBuilder(column: $table.agentType, builder: (column) => column); + + GeneratedColumn get agentId => + $composableBuilder(column: $table.agentId, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get workingDirectory => $composableBuilder( + column: $table.workingDirectory, builder: (column) => column); + + GeneratedColumn get branch => + $composableBuilder(column: $table.branch, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastMessageAt => $composableBuilder( + column: $table.lastMessageAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get synced => + $composableBuilder(column: $table.synced, builder: (column) => column); + + Expression messagesRefs( + Expression Function($$MessagesTableAnnotationComposer a) f) { + final $$MessagesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.sessionId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } + + Expression approvalsRefs( + Expression Function($$ApprovalsTableAnnotationComposer a) f) { + final $$ApprovalsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.approvals, + getReferencedColumn: (t) => t.sessionId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$ApprovalsTableAnnotationComposer( + $db: $db, + $table: $db.approvals, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$SessionsTableTableManager extends RootTableManager< + _$AppDatabase, + $SessionsTable, + Session, + $$SessionsTableFilterComposer, + $$SessionsTableOrderingComposer, + $$SessionsTableAnnotationComposer, + $$SessionsTableCreateCompanionBuilder, + $$SessionsTableUpdateCompanionBuilder, + (Session, $$SessionsTableReferences), + Session, + PrefetchHooks Function({bool messagesRefs, bool approvalsRefs})> { + $$SessionsTableTableManager(_$AppDatabase db, $SessionsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SessionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SessionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SessionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value agentType = const Value.absent(), + Value agentId = const Value.absent(), + Value title = const Value.absent(), + Value workingDirectory = const Value.absent(), + Value branch = const Value.absent(), + Value status = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SessionsCompanion( + id: id, + agentType: agentType, + agentId: agentId, + title: title, + workingDirectory: workingDirectory, + branch: branch, + status: status, + createdAt: createdAt, + lastMessageAt: lastMessageAt, + updatedAt: updatedAt, + synced: synced, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String agentType, + Value agentId = const Value.absent(), + Value title = const Value.absent(), + required String workingDirectory, + Value branch = const Value.absent(), + required String status, + required DateTime createdAt, + Value lastMessageAt = const Value.absent(), + required DateTime updatedAt, + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + SessionsCompanion.insert( + id: id, + agentType: agentType, + agentId: agentId, + title: title, + workingDirectory: workingDirectory, + branch: branch, + status: status, + createdAt: createdAt, + lastMessageAt: lastMessageAt, + updatedAt: updatedAt, + synced: synced, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => + (e.readTable(table), $$SessionsTableReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ( + {messagesRefs = false, approvalsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (messagesRefs) db.messages, + if (approvalsRefs) db.approvals + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (messagesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: + $$SessionsTableReferences._messagesRefsTable(db), + managerFromTypedResult: (p0) => + $$SessionsTableReferences(db, table, p0) + .messagesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems + .where((e) => e.sessionId == item.id), + typedResults: items), + if (approvalsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: + $$SessionsTableReferences._approvalsRefsTable(db), + managerFromTypedResult: (p0) => + $$SessionsTableReferences(db, table, p0) + .approvalsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems + .where((e) => e.sessionId == item.id), + typedResults: items) + ]; + }, + ); + }, + )); +} + +typedef $$SessionsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $SessionsTable, + Session, + $$SessionsTableFilterComposer, + $$SessionsTableOrderingComposer, + $$SessionsTableAnnotationComposer, + $$SessionsTableCreateCompanionBuilder, + $$SessionsTableUpdateCompanionBuilder, + (Session, $$SessionsTableReferences), + Session, + PrefetchHooks Function({bool messagesRefs, bool approvalsRefs})>; +typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ + required String id, + required String sessionId, + required String role, + required String content, + Value messageType, + Value metadata, + required DateTime createdAt, + required DateTime updatedAt, + Value synced, + Value rowid, +}); +typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ + Value id, + Value sessionId, + Value role, + Value content, + Value messageType, + Value metadata, + Value createdAt, + Value updatedAt, + Value synced, + Value rowid, +}); + +final class $$MessagesTableReferences + extends BaseReferences<_$AppDatabase, $MessagesTable, Message> { + $$MessagesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $SessionsTable _sessionIdTable(_$AppDatabase db) => db.sessions + .createAlias($_aliasNameGenerator(db.messages.sessionId, db.sessions.id)); + + $$SessionsTableProcessedTableManager get sessionId { + final $_column = $_itemColumn('session_id')!; + + final manager = $$SessionsTableTableManager($_db, $_db.sessions) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_sessionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$MessagesTableFilterComposer + extends Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get role => $composableBuilder( + column: $table.role, builder: (column) => ColumnFilters(column)); + + ColumnFilters get content => $composableBuilder( + column: $table.content, builder: (column) => ColumnFilters(column)); + + ColumnFilters get messageType => $composableBuilder( + column: $table.messageType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get metadata => $composableBuilder( + column: $table.metadata, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnFilters(column)); + + $$SessionsTableFilterComposer get sessionId { + final $$SessionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableFilterComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MessagesTableOrderingComposer + extends Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get role => $composableBuilder( + column: $table.role, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get messageType => $composableBuilder( + column: $table.messageType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get metadata => $composableBuilder( + column: $table.metadata, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnOrderings(column)); + + $$SessionsTableOrderingComposer get sessionId { + final $$SessionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableOrderingComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MessagesTableAnnotationComposer + extends Composer<_$AppDatabase, $MessagesTable> { + $$MessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get role => + $composableBuilder(column: $table.role, builder: (column) => column); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get messageType => $composableBuilder( + column: $table.messageType, builder: (column) => column); + + GeneratedColumn get metadata => + $composableBuilder(column: $table.metadata, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get synced => + $composableBuilder(column: $table.synced, builder: (column) => column); + + $$SessionsTableAnnotationComposer get sessionId { + final $$SessionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableAnnotationComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MessagesTableTableManager extends RootTableManager< + _$AppDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, $$MessagesTableReferences), + Message, + PrefetchHooks Function({bool sessionId})> { + $$MessagesTableTableManager(_$AppDatabase db, $MessagesTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value sessionId = const Value.absent(), + Value role = const Value.absent(), + Value content = const Value.absent(), + Value messageType = const Value.absent(), + Value metadata = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + MessagesCompanion( + id: id, + sessionId: sessionId, + role: role, + content: content, + messageType: messageType, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + synced: synced, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String sessionId, + required String role, + required String content, + Value messageType = const Value.absent(), + Value metadata = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + MessagesCompanion.insert( + id: id, + sessionId: sessionId, + role: role, + content: content, + messageType: messageType, + metadata: metadata, + createdAt: createdAt, + updatedAt: updatedAt, + synced: synced, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => + (e.readTable(table), $$MessagesTableReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ({sessionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (sessionId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.sessionId, + referencedTable: + $$MessagesTableReferences._sessionIdTable(db), + referencedColumn: + $$MessagesTableReferences._sessionIdTable(db).id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$MessagesTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, $$MessagesTableReferences), + Message, + PrefetchHooks Function({bool sessionId})>; +typedef $$AgentsTableCreateCompanionBuilder = AgentsCompanion Function({ + required String id, + required String displayName, + required String agentType, + required String bridgeUrl, + required String authToken, + Value workingDirectory, + Value status, + Value lastConnectedAt, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, +}); +typedef $$AgentsTableUpdateCompanionBuilder = AgentsCompanion Function({ + Value id, + Value displayName, + Value agentType, + Value bridgeUrl, + Value authToken, + Value workingDirectory, + Value status, + Value lastConnectedAt, + Value createdAt, + Value updatedAt, + Value rowid, +}); + +class $$AgentsTableFilterComposer + extends Composer<_$AppDatabase, $AgentsTable> { + $$AgentsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => ColumnFilters(column)); + + ColumnFilters get agentType => $composableBuilder( + column: $table.agentType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get bridgeUrl => $composableBuilder( + column: $table.bridgeUrl, builder: (column) => ColumnFilters(column)); + + ColumnFilters get authToken => $composableBuilder( + column: $table.authToken, builder: (column) => ColumnFilters(column)); + + ColumnFilters get workingDirectory => $composableBuilder( + column: $table.workingDirectory, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnFilters(column)); +} + +class $$AgentsTableOrderingComposer + extends Composer<_$AppDatabase, $AgentsTable> { + $$AgentsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get agentType => $composableBuilder( + column: $table.agentType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get bridgeUrl => $composableBuilder( + column: $table.bridgeUrl, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get authToken => $composableBuilder( + column: $table.authToken, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get workingDirectory => $composableBuilder( + column: $table.workingDirectory, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); +} + +class $$AgentsTableAnnotationComposer + extends Composer<_$AppDatabase, $AgentsTable> { + $$AgentsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => column); + + GeneratedColumn get agentType => + $composableBuilder(column: $table.agentType, builder: (column) => column); + + GeneratedColumn get bridgeUrl => + $composableBuilder(column: $table.bridgeUrl, builder: (column) => column); + + GeneratedColumn get authToken => + $composableBuilder(column: $table.authToken, builder: (column) => column); + + GeneratedColumn get workingDirectory => $composableBuilder( + column: $table.workingDirectory, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get lastConnectedAt => $composableBuilder( + column: $table.lastConnectedAt, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$AgentsTableTableManager extends RootTableManager< + _$AppDatabase, + $AgentsTable, + Agent, + $$AgentsTableFilterComposer, + $$AgentsTableOrderingComposer, + $$AgentsTableAnnotationComposer, + $$AgentsTableCreateCompanionBuilder, + $$AgentsTableUpdateCompanionBuilder, + (Agent, BaseReferences<_$AppDatabase, $AgentsTable, Agent>), + Agent, + PrefetchHooks Function()> { + $$AgentsTableTableManager(_$AppDatabase db, $AgentsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AgentsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$AgentsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$AgentsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value displayName = const Value.absent(), + Value agentType = const Value.absent(), + Value bridgeUrl = const Value.absent(), + Value authToken = const Value.absent(), + Value workingDirectory = const Value.absent(), + Value status = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + AgentsCompanion( + id: id, + displayName: displayName, + agentType: agentType, + bridgeUrl: bridgeUrl, + authToken: authToken, + workingDirectory: workingDirectory, + status: status, + lastConnectedAt: lastConnectedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String displayName, + required String agentType, + required String bridgeUrl, + required String authToken, + Value workingDirectory = const Value.absent(), + Value status = const Value.absent(), + Value lastConnectedAt = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => + AgentsCompanion.insert( + id: id, + displayName: displayName, + agentType: agentType, + bridgeUrl: bridgeUrl, + authToken: authToken, + workingDirectory: workingDirectory, + status: status, + lastConnectedAt: lastConnectedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$AgentsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $AgentsTable, + Agent, + $$AgentsTableFilterComposer, + $$AgentsTableOrderingComposer, + $$AgentsTableAnnotationComposer, + $$AgentsTableCreateCompanionBuilder, + $$AgentsTableUpdateCompanionBuilder, + (Agent, BaseReferences<_$AppDatabase, $AgentsTable, Agent>), + Agent, + PrefetchHooks Function()>; +typedef $$ApprovalsTableCreateCompanionBuilder = ApprovalsCompanion Function({ + required String id, + required String sessionId, + required String tool, + required String description, + required String params, + Value reasoning, + required String riskLevel, + required String decision, + Value modifications, + Value result, + required DateTime createdAt, + Value decidedAt, + Value synced, + Value rowid, +}); +typedef $$ApprovalsTableUpdateCompanionBuilder = ApprovalsCompanion Function({ + Value id, + Value sessionId, + Value tool, + Value description, + Value params, + Value reasoning, + Value riskLevel, + Value decision, + Value modifications, + Value result, + Value createdAt, + Value decidedAt, + Value synced, + Value rowid, +}); + +final class $$ApprovalsTableReferences + extends BaseReferences<_$AppDatabase, $ApprovalsTable, Approval> { + $$ApprovalsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $SessionsTable _sessionIdTable(_$AppDatabase db) => + db.sessions.createAlias( + $_aliasNameGenerator(db.approvals.sessionId, db.sessions.id)); + + $$SessionsTableProcessedTableManager get sessionId { + final $_column = $_itemColumn('session_id')!; + + final manager = $$SessionsTableTableManager($_db, $_db.sessions) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_sessionIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$ApprovalsTableFilterComposer + extends Composer<_$AppDatabase, $ApprovalsTable> { + $$ApprovalsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get tool => $composableBuilder( + column: $table.tool, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); + + ColumnFilters get params => $composableBuilder( + column: $table.params, builder: (column) => ColumnFilters(column)); + + ColumnFilters get reasoning => $composableBuilder( + column: $table.reasoning, builder: (column) => ColumnFilters(column)); + + ColumnFilters get riskLevel => $composableBuilder( + column: $table.riskLevel, builder: (column) => ColumnFilters(column)); + + ColumnFilters get decision => $composableBuilder( + column: $table.decision, builder: (column) => ColumnFilters(column)); + + ColumnFilters get modifications => $composableBuilder( + column: $table.modifications, builder: (column) => ColumnFilters(column)); + + ColumnFilters get result => $composableBuilder( + column: $table.result, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get decidedAt => $composableBuilder( + column: $table.decidedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnFilters(column)); + + $$SessionsTableFilterComposer get sessionId { + final $$SessionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableFilterComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$ApprovalsTableOrderingComposer + extends Composer<_$AppDatabase, $ApprovalsTable> { + $$ApprovalsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get tool => $composableBuilder( + column: $table.tool, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get params => $composableBuilder( + column: $table.params, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get reasoning => $composableBuilder( + column: $table.reasoning, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get riskLevel => $composableBuilder( + column: $table.riskLevel, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get decision => $composableBuilder( + column: $table.decision, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get modifications => $composableBuilder( + column: $table.modifications, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get result => $composableBuilder( + column: $table.result, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get decidedAt => $composableBuilder( + column: $table.decidedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnOrderings(column)); + + $$SessionsTableOrderingComposer get sessionId { + final $$SessionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableOrderingComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$ApprovalsTableAnnotationComposer + extends Composer<_$AppDatabase, $ApprovalsTable> { + $$ApprovalsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get tool => + $composableBuilder(column: $table.tool, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); + + GeneratedColumn get params => + $composableBuilder(column: $table.params, builder: (column) => column); + + GeneratedColumn get reasoning => + $composableBuilder(column: $table.reasoning, builder: (column) => column); + + GeneratedColumn get riskLevel => + $composableBuilder(column: $table.riskLevel, builder: (column) => column); + + GeneratedColumn get decision => + $composableBuilder(column: $table.decision, builder: (column) => column); + + GeneratedColumn get modifications => $composableBuilder( + column: $table.modifications, builder: (column) => column); + + GeneratedColumn get result => + $composableBuilder(column: $table.result, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get decidedAt => + $composableBuilder(column: $table.decidedAt, builder: (column) => column); + + GeneratedColumn get synced => + $composableBuilder(column: $table.synced, builder: (column) => column); + + $$SessionsTableAnnotationComposer get sessionId { + final $$SessionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.sessionId, + referencedTable: $db.sessions, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$SessionsTableAnnotationComposer( + $db: $db, + $table: $db.sessions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$ApprovalsTableTableManager extends RootTableManager< + _$AppDatabase, + $ApprovalsTable, + Approval, + $$ApprovalsTableFilterComposer, + $$ApprovalsTableOrderingComposer, + $$ApprovalsTableAnnotationComposer, + $$ApprovalsTableCreateCompanionBuilder, + $$ApprovalsTableUpdateCompanionBuilder, + (Approval, $$ApprovalsTableReferences), + Approval, + PrefetchHooks Function({bool sessionId})> { + $$ApprovalsTableTableManager(_$AppDatabase db, $ApprovalsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ApprovalsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ApprovalsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ApprovalsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value sessionId = const Value.absent(), + Value tool = const Value.absent(), + Value description = const Value.absent(), + Value params = const Value.absent(), + Value reasoning = const Value.absent(), + Value riskLevel = const Value.absent(), + Value decision = const Value.absent(), + Value modifications = const Value.absent(), + Value result = const Value.absent(), + Value createdAt = const Value.absent(), + Value decidedAt = const Value.absent(), + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ApprovalsCompanion( + id: id, + sessionId: sessionId, + tool: tool, + description: description, + params: params, + reasoning: reasoning, + riskLevel: riskLevel, + decision: decision, + modifications: modifications, + result: result, + createdAt: createdAt, + decidedAt: decidedAt, + synced: synced, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String sessionId, + required String tool, + required String description, + required String params, + Value reasoning = const Value.absent(), + required String riskLevel, + required String decision, + Value modifications = const Value.absent(), + Value result = const Value.absent(), + required DateTime createdAt, + Value decidedAt = const Value.absent(), + Value synced = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ApprovalsCompanion.insert( + id: id, + sessionId: sessionId, + tool: tool, + description: description, + params: params, + reasoning: reasoning, + riskLevel: riskLevel, + decision: decision, + modifications: modifications, + result: result, + createdAt: createdAt, + decidedAt: decidedAt, + synced: synced, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + $$ApprovalsTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({sessionId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (sessionId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.sessionId, + referencedTable: + $$ApprovalsTableReferences._sessionIdTable(db), + referencedColumn: + $$ApprovalsTableReferences._sessionIdTable(db).id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$ApprovalsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $ApprovalsTable, + Approval, + $$ApprovalsTableFilterComposer, + $$ApprovalsTableOrderingComposer, + $$ApprovalsTableAnnotationComposer, + $$ApprovalsTableCreateCompanionBuilder, + $$ApprovalsTableUpdateCompanionBuilder, + (Approval, $$ApprovalsTableReferences), + Approval, + PrefetchHooks Function({bool sessionId})>; +typedef $$SyncQueueTableCreateCompanionBuilder = SyncQueueCompanion Function({ + Value id, + required String operation, + required String payload, + Value sessionId, + required DateTime createdAt, + Value synced, + Value retryCount, + Value lastError, +}); +typedef $$SyncQueueTableUpdateCompanionBuilder = SyncQueueCompanion Function({ + Value id, + Value operation, + Value payload, + Value sessionId, + Value createdAt, + Value synced, + Value retryCount, + Value lastError, +}); + +class $$SyncQueueTableFilterComposer + extends Composer<_$AppDatabase, $SyncQueueTable> { + $$SyncQueueTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get operation => $composableBuilder( + column: $table.operation, builder: (column) => ColumnFilters(column)); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnFilters(column)); + + ColumnFilters get sessionId => $composableBuilder( + column: $table.sessionId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnFilters(column)); + + ColumnFilters get retryCount => $composableBuilder( + column: $table.retryCount, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, builder: (column) => ColumnFilters(column)); +} + +class $$SyncQueueTableOrderingComposer + extends Composer<_$AppDatabase, $SyncQueueTable> { + $$SyncQueueTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get operation => $composableBuilder( + column: $table.operation, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sessionId => $composableBuilder( + column: $table.sessionId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get synced => $composableBuilder( + column: $table.synced, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get retryCount => $composableBuilder( + column: $table.retryCount, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, builder: (column) => ColumnOrderings(column)); +} + +class $$SyncQueueTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncQueueTable> { + $$SyncQueueTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get operation => + $composableBuilder(column: $table.operation, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get sessionId => + $composableBuilder(column: $table.sessionId, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get synced => + $composableBuilder(column: $table.synced, builder: (column) => column); + + GeneratedColumn get retryCount => $composableBuilder( + column: $table.retryCount, builder: (column) => column); + + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); +} + +class $$SyncQueueTableTableManager extends RootTableManager< + _$AppDatabase, + $SyncQueueTable, + SyncQueueData, + $$SyncQueueTableFilterComposer, + $$SyncQueueTableOrderingComposer, + $$SyncQueueTableAnnotationComposer, + $$SyncQueueTableCreateCompanionBuilder, + $$SyncQueueTableUpdateCompanionBuilder, + ( + SyncQueueData, + BaseReferences<_$AppDatabase, $SyncQueueTable, SyncQueueData> + ), + SyncQueueData, + PrefetchHooks Function()> { + $$SyncQueueTableTableManager(_$AppDatabase db, $SyncQueueTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncQueueTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncQueueTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncQueueTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value operation = const Value.absent(), + Value payload = const Value.absent(), + Value sessionId = const Value.absent(), + Value createdAt = const Value.absent(), + Value synced = const Value.absent(), + Value retryCount = const Value.absent(), + Value lastError = const Value.absent(), + }) => + SyncQueueCompanion( + id: id, + operation: operation, + payload: payload, + sessionId: sessionId, + createdAt: createdAt, + synced: synced, + retryCount: retryCount, + lastError: lastError, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String operation, + required String payload, + Value sessionId = const Value.absent(), + required DateTime createdAt, + Value synced = const Value.absent(), + Value retryCount = const Value.absent(), + Value lastError = const Value.absent(), + }) => + SyncQueueCompanion.insert( + id: id, + operation: operation, + payload: payload, + sessionId: sessionId, + createdAt: createdAt, + synced: synced, + retryCount: retryCount, + lastError: lastError, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$SyncQueueTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $SyncQueueTable, + SyncQueueData, + $$SyncQueueTableFilterComposer, + $$SyncQueueTableOrderingComposer, + $$SyncQueueTableAnnotationComposer, + $$SyncQueueTableCreateCompanionBuilder, + $$SyncQueueTableUpdateCompanionBuilder, + ( + SyncQueueData, + BaseReferences<_$AppDatabase, $SyncQueueTable, SyncQueueData> + ), + SyncQueueData, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$SessionsTableTableManager get sessions => + $$SessionsTableTableManager(_db, _db.sessions); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db, _db.messages); + $$AgentsTableTableManager get agents => + $$AgentsTableTableManager(_db, _db.agents); + $$ApprovalsTableTableManager get approvals => + $$ApprovalsTableTableManager(_db, _db.approvals); + $$SyncQueueTableTableManager get syncQueue => + $$SyncQueueTableTableManager(_db, _db.syncQueue); +} diff --git a/apps/mobile/lib/core/storage/preferences.dart b/apps/mobile/lib/core/storage/preferences.dart new file mode 100644 index 0000000..6847249 --- /dev/null +++ b/apps/mobile/lib/core/storage/preferences.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +const String _kBoxName = 'preferences'; +const String _kThemeMode = 'theme_mode'; +const String _kDefaultAgentId = 'default_agent_id'; +const String _kNotificationsEnabled = 'notifications_enabled'; +const String _kBridgeUrl = 'bridge_url'; +const String _kHighContrast = 'high_contrast'; + +/// App preferences backed by a Hive key-value box. +/// No HiveType annotations are needed — this class uses a dynamic string-keyed box. +class AppPreferences { + late Box _box; + + Future init() async { + _box = await Hive.openBox(_kBoxName); + } + + ThemeMode getThemeMode() { + final value = _box.get(_kThemeMode, defaultValue: 'system') as String; + return switch (value) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; + } + + Future setThemeMode(ThemeMode mode) async { + final value = switch (mode) { + ThemeMode.light => 'light', + ThemeMode.dark => 'dark', + ThemeMode.system => 'system', + }; + await _box.put(_kThemeMode, value); + } + + String? getDefaultAgentId() { + return _box.get(_kDefaultAgentId) as String?; + } + + Future setDefaultAgentId(String? id) async { + if (id == null) { + await _box.delete(_kDefaultAgentId); + } else { + await _box.put(_kDefaultAgentId, id); + } + } + + bool getNotificationsEnabled() { + return _box.get(_kNotificationsEnabled, defaultValue: true) as bool; + } + + Future setNotificationsEnabled(bool enabled) async { + await _box.put(_kNotificationsEnabled, enabled); + } + + String? getBridgeUrl() { + return _box.get(_kBridgeUrl) as String?; + } + + Future setBridgeUrl(String? url) async { + if (url == null) { + await _box.delete(_kBridgeUrl); + } else { + await _box.put(_kBridgeUrl, url); + } + } + + bool getHighContrast() { + return _box.get(_kHighContrast, defaultValue: false) as bool; + } + + Future setHighContrast(bool val) async { + await _box.put(_kHighContrast, val); + } +} diff --git a/apps/mobile/lib/core/storage/secure_token_storage.dart b/apps/mobile/lib/core/storage/secure_token_storage.dart new file mode 100644 index 0000000..2b56450 --- /dev/null +++ b/apps/mobile/lib/core/storage/secure_token_storage.dart @@ -0,0 +1,28 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const String kBridgeToken = 'bridge_token'; + +/// Secure key-value storage for bridge pairing tokens. +/// +/// Backed by [FlutterSecureStorage] (Keychain on iOS, Keystore on Android). +class SecureTokenStorage { + const SecureTokenStorage(this._storage); + + final FlutterSecureStorage _storage; + + Future saveToken(String key, String value) async { + await _storage.write(key: key, value: value); + } + + Future getToken(String key) async { + return _storage.read(key: key); + } + + Future deleteToken(String key) async { + await _storage.delete(key: key); + } + + Future clearAll() async { + await _storage.deleteAll(); + } +} diff --git a/apps/mobile/lib/core/storage/tables/agents_table.dart b/apps/mobile/lib/core/storage/tables/agents_table.dart new file mode 100644 index 0000000..d7c1c50 --- /dev/null +++ b/apps/mobile/lib/core/storage/tables/agents_table.dart @@ -0,0 +1,29 @@ +import 'package:drift/drift.dart'; + +/// Stores configured agent connections. +class Agents extends Table { + TextColumn get id => text()(); + TextColumn get displayName => text()(); + + /// "claude-code" | "opencode" | "aider" | "goose" | "custom" + TextColumn get agentType => text()(); + + /// WebSocket bridge URL, e.g. "wss://100.78.42.15:3000" + TextColumn get bridgeUrl => text()(); + + /// Encrypted bridge auth token. + TextColumn get authToken => text()(); + + TextColumn get workingDirectory => text().nullable()(); + + /// "connected" | "disconnected" | "inactive" + TextColumn get status => + text().withDefault(const Constant('disconnected'))(); + + DateTimeColumn get lastConnectedAt => dateTime().nullable()(); + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/apps/mobile/lib/core/storage/tables/approvals_table.dart b/apps/mobile/lib/core/storage/tables/approvals_table.dart new file mode 100644 index 0000000..4a125d6 --- /dev/null +++ b/apps/mobile/lib/core/storage/tables/approvals_table.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; + +import 'sessions_table.dart'; + +/// Stores tool call approval history. +class Approvals extends Table { + TextColumn get id => text()(); + TextColumn get sessionId => text().references(Sessions, #id)(); + TextColumn get tool => text()(); + TextColumn get description => text()(); + + /// JSON: tool parameters. + TextColumn get params => text()(); + + /// Agent's explanation. + TextColumn get reasoning => text().nullable()(); + + /// "low" | "medium" | "high" | "critical" + TextColumn get riskLevel => text()(); + + /// "approved" | "rejected" | "modified" | "pending" + TextColumn get decision => text()(); + + /// User's modification instructions. + TextColumn get modifications => text().nullable()(); + + /// JSON: tool execution result. + TextColumn get result => text().nullable()(); + + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get decidedAt => dateTime().nullable()(); + BoolColumn get synced => boolean().withDefault(const Constant(true))(); + + @override + Set get primaryKey => {id}; +} diff --git a/apps/mobile/lib/core/storage/tables/messages_table.dart b/apps/mobile/lib/core/storage/tables/messages_table.dart new file mode 100644 index 0000000..ec664b6 --- /dev/null +++ b/apps/mobile/lib/core/storage/tables/messages_table.dart @@ -0,0 +1,29 @@ +import 'package:drift/drift.dart'; + +import 'sessions_table.dart'; + +/// Stores chat messages within sessions. +class Messages extends Table { + TextColumn get id => text()(); + TextColumn get sessionId => text().references(Sessions, #id)(); + + /// "user" | "agent" | "system" + TextColumn get role => text()(); + + /// Full message text (markdown). + TextColumn get content => text()(); + + /// "text" | "tool_call" | "tool_result" | "system" + TextColumn get messageType => + text().withDefault(const Constant('text'))(); + + /// JSON: token count, tool info, etc. + TextColumn get metadata => text().nullable()(); + + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + BoolColumn get synced => boolean().withDefault(const Constant(true))(); + + @override + Set get primaryKey => {id}; +} diff --git a/apps/mobile/lib/core/storage/tables/sessions_table.dart b/apps/mobile/lib/core/storage/tables/sessions_table.dart new file mode 100644 index 0000000..e9681be --- /dev/null +++ b/apps/mobile/lib/core/storage/tables/sessions_table.dart @@ -0,0 +1,22 @@ +import 'package:drift/drift.dart'; + +/// Stores agent chat sessions. +class Sessions extends Table { + TextColumn get id => text()(); + TextColumn get agentType => text()(); + TextColumn get agentId => text().nullable()(); + TextColumn get title => text().withDefault(const Constant(''))(); + TextColumn get workingDirectory => text()(); + TextColumn get branch => text().nullable()(); + + /// "active" | "paused" | "closed" + TextColumn get status => text()(); + + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get lastMessageAt => dateTime().nullable()(); + DateTimeColumn get updatedAt => dateTime()(); + BoolColumn get synced => boolean().withDefault(const Constant(true))(); + + @override + Set get primaryKey => {id}; +} diff --git a/apps/mobile/lib/core/storage/tables/sync_queue_table.dart b/apps/mobile/lib/core/storage/tables/sync_queue_table.dart new file mode 100644 index 0000000..2141fd5 --- /dev/null +++ b/apps/mobile/lib/core/storage/tables/sync_queue_table.dart @@ -0,0 +1,19 @@ +import 'package:drift/drift.dart'; + +/// Offline mutation queue — persists operations until they can be flushed +/// to the bridge server after reconnection. +class SyncQueue extends Table { + IntColumn get id => integer().autoIncrement()(); + + /// "send_message" | "approve_tool" | "git_command" + TextColumn get operation => text()(); + + /// JSON: full operation payload. + TextColumn get payload => text()(); + + TextColumn get sessionId => text().nullable()(); + DateTimeColumn get createdAt => dateTime()(); + BoolColumn get synced => boolean().withDefault(const Constant(false))(); + IntColumn get retryCount => integer().withDefault(const Constant(0))(); + TextColumn get lastError => text().nullable()(); +} diff --git a/apps/mobile/lib/core/sync/conflict_resolver.dart b/apps/mobile/lib/core/sync/conflict_resolver.dart new file mode 100644 index 0000000..bdfa996 --- /dev/null +++ b/apps/mobile/lib/core/sync/conflict_resolver.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +/// Conflict resolution strategies for offline sync. +class ConflictResolver { + const ConflictResolver(); + + /// Last-write-wins: return whichever record has the later [updatedAt]. + /// If both are equal, prefer [remote]. + T resolve(T local, T remote) { + if (local.updatedAt == null) return remote; + if (remote.updatedAt == null) return local; + return local.updatedAt!.isAfter(remote.updatedAt!) ? local : remote; + } + + /// Show a dialog asking the user which version to keep. + /// Returns the chosen record. + Future resolveWithUserPrompt( + T local, + T remote, + BuildContext context, + ) async { + final choice = await showDialog<_ConflictChoice>( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Sync Conflict'), + content: const Text( + 'This item was modified both locally and on the server. ' + 'Which version would you like to keep?', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(ctx).pop(_ConflictChoice.local), + child: const Text('Keep Mine'), + ), + TextButton( + onPressed: () => + Navigator.of(ctx).pop(_ConflictChoice.remote), + child: const Text('Use Server Version'), + ), + ], + ), + ); + + return switch (choice) { + _ConflictChoice.local => local, + _ => remote, + }; + } +} + +enum _ConflictChoice { local, remote } + +/// Mixin contract for types that expose an [updatedAt] timestamp. +abstract interface class _HasUpdatedAt { + DateTime? get updatedAt; +} diff --git a/apps/mobile/lib/core/sync/sync_queue.dart b/apps/mobile/lib/core/sync/sync_queue.dart new file mode 100644 index 0000000..9d8832e --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_queue.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; + +import '../network/connection_state.dart'; +import '../network/websocket_messages.dart'; +import '../network/websocket_service.dart'; +import '../storage/daos/sync_dao.dart'; +import '../storage/database.dart'; + +/// Manages an offline-first mutation queue backed by [SyncDao]. +/// Operations enqueued while disconnected are replayed on reconnect via [flush]. +class SyncQueueService { + SyncQueueService({required AppDatabase database}) + : _database = database, + _dao = database.syncDao; + + final AppDatabase _database; + final SyncDao _dao; + + Future enqueue( + String operation, + Map payload, { + String? sessionId, + }) async { + await _dao.enqueue( + SyncQueueCompanion( + operation: Value(operation), + payload: Value(jsonEncode(payload)), + sessionId: Value(sessionId), + createdAt: Value(DateTime.now().toUtc()), + ), + ); + } + + /// Dequeue all pending items and send them over [ws]. + /// Items that succeed are marked as synced; failures increment retry count. + Future flush(WebSocketService ws) async { + final pending = await _dao.getPendingItems(); + for (final item in pending) { + try { + if (ws.currentStatus != ConnectionStatus.connected) { + await _dao.incrementRetry(item.id, 'Bridge unavailable'); + continue; + } + + final payloadMap = _decodePayload(item.payload); + final message = _buildMessage(item.operation, payloadMap); + final sent = ws.send(message); + if (!sent) { + await _dao.incrementRetry(item.id, 'Bridge unavailable'); + continue; + } + + await _dao.markSynced(item.id); + await _markLocalArtifactsSynced(item.operation, payloadMap); + } catch (e) { + await _dao.incrementRetry(item.id, e.toString()); + } + } + } + + Future getPendingCount() async { + final items = await _dao.getPendingItems(); + return items.length; + } + + Map _decodePayload(String payload) { + return jsonDecode(payload) as Map; + } + + BridgeMessage _buildMessage( + String operation, + Map payload, + ) { + switch (operation) { + case 'message': + return BridgeMessage.message( + sessionId: payload['session_id'] as String? ?? '', + content: payload['content'] as String? ?? '', + role: payload['role'] as String? ?? 'user', + ); + case 'session_start': + return BridgeMessage.sessionStart( + agent: payload['agent'] as String? ?? 'claude-code', + sessionId: payload['session_id'] as String?, + workingDirectory: payload['working_directory'] as String? ?? '', + resume: payload['resume'] as bool? ?? false, + ); + case 'session_end': + return BridgeMessage.sessionEnd( + sessionId: payload['session_id'] as String? ?? '', + reason: payload['reason'] as String? ?? 'user_request', + ); + case 'approval_response': + return BridgeMessage.approvalResponse( + sessionId: payload['session_id'] as String? ?? '', + toolCallId: payload['tool_call_id'] as String? ?? '', + decision: payload['decision'] as String? ?? 'rejected', + modifications: payload['modifications'] as Map?, + ); + case 'notification_ack': + return BridgeMessage.notificationAck( + notificationIds: (payload['notification_ids'] as List? ?? []) + .whereType() + .toList(), + ); + } + + throw UnsupportedError('Unsupported sync queue operation: $operation'); + } + + Future _markLocalArtifactsSynced( + String operation, + Map payload, + ) async { + if (operation == 'message') { + final localMessageId = payload['local_message_id'] as String?; + if (localMessageId == null || localMessageId.isEmpty) { + return; + } + + final existingMessage = await (_database.select(_database.messages) + ..where((message) => message.id.equals(localMessageId))) + .getSingleOrNull(); + if (existingMessage == null) { + return; + } + + await _database.messageDao.updateMessage( + MessagesCompanion( + id: Value(existingMessage.id), + sessionId: Value(existingMessage.sessionId), + role: Value(existingMessage.role), + content: Value(existingMessage.content), + messageType: Value(existingMessage.messageType), + metadata: Value(existingMessage.metadata), + createdAt: Value(existingMessage.createdAt), + updatedAt: Value(DateTime.now().toUtc()), + synced: const Value(true), + ), + ); + return; + } + + if (operation == 'session_start') { + final sessionId = payload['session_id'] as String?; + if (sessionId == null || sessionId.isEmpty) { + return; + } + + final existingSession = await _database.sessionDao.getSession(sessionId); + if (existingSession == null) { + return; + } + + await _database.sessionDao.upsertSession( + SessionsCompanion( + id: Value(existingSession.id), + agentType: Value(existingSession.agentType), + agentId: Value(existingSession.agentId), + title: Value(existingSession.title), + workingDirectory: Value(existingSession.workingDirectory), + branch: Value(existingSession.branch), + status: Value(existingSession.status), + createdAt: Value(existingSession.createdAt), + lastMessageAt: Value(existingSession.lastMessageAt), + updatedAt: Value(DateTime.now().toUtc()), + synced: const Value(true), + ), + ); + } + } +} diff --git a/apps/mobile/lib/core/sync/sync_service.dart b/apps/mobile/lib/core/sync/sync_service.dart new file mode 100644 index 0000000..09cf5c3 --- /dev/null +++ b/apps/mobile/lib/core/sync/sync_service.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +import '../network/connection_state.dart'; +import '../network/websocket_service.dart'; +import '../notifications/notification_center.dart'; +import 'sync_queue.dart'; + +/// Orchestrates sync on reconnection. +/// Listens to [WebSocketService.connectionStatus] and triggers queue flush +/// when the connection is (re-)established. +class SyncService { + SyncService({ + required WebSocketService webSocketService, + required SyncQueueService syncQueue, + required NotificationCenter notificationCenter, + }) : _ws = webSocketService, + _syncQueue = syncQueue, + _notificationCenter = notificationCenter; + + final WebSocketService _ws; + final SyncQueueService _syncQueue; + final NotificationCenter _notificationCenter; + + StreamSubscription? _statusSubscription; + bool _running = false; + + void start() { + if (_running) return; + _running = true; + + _statusSubscription = _ws.connectionStatus.listen((status) async { + if (status == ConnectionStatus.connected) { + await _onReconnected(); + } + }); + } + + Future _onReconnected() async { + // Flush queued messages. + await _syncQueue.flush(_ws); + } + + void stop() { + _running = false; + _statusSubscription?.cancel(); + _statusSubscription = null; + } +} diff --git a/apps/mobile/lib/features/agents/domain/providers/agent_provider.dart b/apps/mobile/lib/features/agents/domain/providers/agent_provider.dart new file mode 100644 index 0000000..0e562c8 --- /dev/null +++ b/apps/mobile/lib/features/agents/domain/providers/agent_provider.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/models/agent_models.dart'; +import '../../../../core/providers/database_provider.dart'; +import '../../../../core/storage/database.dart'; +import '../../../../core/storage/tables/agents_table.dart'; + +const _uuid = Uuid(); + +// --------------------------------------------------------------------------- +// Agents AsyncNotifier +// --------------------------------------------------------------------------- + +class AgentNotifier extends AsyncNotifier> { + @override + Future> build() async { + return _load(); + } + + Future> _load() async { + final db = ref.read(databaseProvider); + final rows = await db.select(db.agents).get(); + return rows.map(_rowToModel).toList(); + } + + AgentConfig _rowToModel(dynamic row) { + return AgentConfig( + id: row.id as String, + displayName: row.displayName as String, + type: AgentType.values.firstWhere( + (e) => e.name == (row.agentType as String), + orElse: () => AgentType.custom, + ), + bridgeUrl: row.bridgeUrl as String, + authToken: row.authToken as String, + workingDirectory: row.workingDirectory as String?, + status: AgentConnectionStatus.values.firstWhere( + (e) => e.name == (row.status as String), + orElse: () => AgentConnectionStatus.disconnected, + ), + lastConnectedAt: row.lastConnectedAt as DateTime?, + createdAt: row.createdAt as DateTime, + updatedAt: row.updatedAt as DateTime, + ); + } + + Future load() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_load); + } + + Future add(AgentConfig agent) async { + final db = ref.read(databaseProvider); + await db.into(db.agents).insert(AgentsCompanion( + id: Value(agent.id), + displayName: Value(agent.displayName), + agentType: Value(agent.type.name), + bridgeUrl: Value(agent.bridgeUrl), + authToken: Value(agent.authToken), + workingDirectory: Value(agent.workingDirectory), + status: Value(agent.status.name), + lastConnectedAt: Value(agent.lastConnectedAt), + createdAt: Value(agent.createdAt), + updatedAt: Value(agent.updatedAt), + )); + await load(); + } + + Future updateAgent(AgentConfig agent) async { + final db = ref.read(databaseProvider); + await db.into(db.agents).insertOnConflictUpdate(AgentsCompanion( + id: Value(agent.id), + displayName: Value(agent.displayName), + agentType: Value(agent.type.name), + bridgeUrl: Value(agent.bridgeUrl), + authToken: Value(agent.authToken), + workingDirectory: Value(agent.workingDirectory), + status: Value(agent.status.name), + lastConnectedAt: Value(agent.lastConnectedAt), + createdAt: Value(agent.createdAt), + updatedAt: Value(DateTime.now()), + )); + await load(); + } + + Future delete(String id) async { + final db = ref.read(databaseProvider); + await (db.delete(db.agents)..where((a) => a.id.equals(id))).go(); + await load(); + } + + /// Attempts to connect to the given bridge and verifies a `connection_ack` + /// response. Returns `true` on success, `false` on failure. + Future testConnection(String bridgeUrl, String token) async { + WebSocketChannel? channel; + try { + final uri = Uri.parse(bridgeUrl); + channel = WebSocketChannel.connect(uri); + await channel.ready; + + channel.sink.add(jsonEncode({ + 'type': 'auth', + 'payload': { + 'token': token, + 'client_version': '0.1.0', + 'platform': 'flutter', + }, + 'timestamp': DateTime.now().toIso8601String(), + 'id': 'test-${_uuid.v4()}', + })); + + // Wait up to 5 seconds for connection_ack. + final completer = Completer(); + late StreamSubscription sub; + final timeout = Timer(const Duration(seconds: 5), () { + if (!completer.isCompleted) completer.complete(false); + }); + + sub = channel.stream.listen( + (data) { + try { + final json = jsonDecode(data as String) as Map; + if (json['type'] == 'connection_ack') { + if (!completer.isCompleted) completer.complete(true); + } + } catch (_) {} + }, + onError: (_) { + if (!completer.isCompleted) completer.complete(false); + }, + onDone: () { + if (!completer.isCompleted) completer.complete(false); + }, + ); + + final result = await completer.future; + timeout.cancel(); + sub.cancel(); + await channel.sink.close(); + return result; + } catch (_) { + await channel?.sink.close(); + return false; + } + } +} + +final agentsProvider = + AsyncNotifierProvider>(AgentNotifier.new); diff --git a/apps/mobile/lib/features/agents/presentation/screens/agent_config_screen.dart b/apps/mobile/lib/features/agents/presentation/screens/agent_config_screen.dart new file mode 100644 index 0000000..d5ac5ab --- /dev/null +++ b/apps/mobile/lib/features/agents/presentation/screens/agent_config_screen.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/models/agent_models.dart'; +import '../../domain/providers/agent_provider.dart'; + +const _uuid = Uuid(); + +/// Create or edit an [AgentConfig]. +/// +/// Pass an existing [AgentConfig] via route `extra` for edit mode. +class AgentConfigScreen extends ConsumerStatefulWidget { + final AgentConfig? existingAgent; + + const AgentConfigScreen({super.key, this.existingAgent}); + + @override + ConsumerState createState() => _AgentConfigScreenState(); +} + +class _AgentConfigScreenState extends ConsumerState { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _bridgeUrlController; + late final TextEditingController _tokenController; + late AgentType _agentType; + bool _obscureToken = true; + bool _isTesting = false; + bool _isSaving = false; + + bool get _isEditMode => widget.existingAgent != null; + + @override + void initState() { + super.initState(); + final a = widget.existingAgent; + _nameController = TextEditingController(text: a?.displayName ?? ''); + _bridgeUrlController = TextEditingController(text: a?.bridgeUrl ?? ''); + _tokenController = TextEditingController(text: a?.authToken ?? ''); + _agentType = a?.type ?? AgentType.claudeCode; + } + + @override + void dispose() { + _nameController.dispose(); + _bridgeUrlController.dispose(); + _tokenController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _isSaving = true); + + final now = DateTime.now(); + final agent = AgentConfig( + id: widget.existingAgent?.id ?? _uuid.v4(), + displayName: _nameController.text.trim(), + type: _agentType, + bridgeUrl: _bridgeUrlController.text.trim(), + authToken: _tokenController.text.trim(), + status: AgentConnectionStatus.disconnected, + createdAt: widget.existingAgent?.createdAt ?? now, + updatedAt: now, + ); + + if (_isEditMode) { + await ref.read(agentsProvider.notifier).updateAgent(agent); + } else { + await ref.read(agentsProvider.notifier).add(agent); + } + + if (mounted) { + setState(() => _isSaving = false); + context.pop(); + } + } + + Future _delete() async { + final id = widget.existingAgent!.id; + await ref.read(agentsProvider.notifier).delete(id); + if (mounted) context.pop(); + } + + Future _testConnection() async { + setState(() => _isTesting = true); + final ok = await ref.read(agentsProvider.notifier).testConnection( + _bridgeUrlController.text.trim(), + _tokenController.text.trim(), + ); + if (mounted) { + setState(() => _isTesting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ok ? 'Connection successful' : 'Connection failed'), + backgroundColor: + ok ? const Color(0xFF4CAF50) : const Color(0xFFF44747), + ), + ); + } + } + + Future _scanQr() async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const _QrScanPage()), + ); + if (result != null) { + _bridgeUrlController.text = result; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditMode ? 'Edit Agent' : 'Add Agent'), + actions: [ + if (_isEditMode) + PopupMenuButton( + onSelected: (value) { + if (value == 'delete') _delete(); + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], + ), + TextButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Display name + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Display Name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + + // Agent type dropdown + DropdownButtonFormField( + value: _agentType, + decoration: const InputDecoration(labelText: 'Agent Type'), + dropdownColor: const Color(0xFF252526), + items: AgentType.values + .map((t) => DropdownMenuItem( + value: t, + child: Text(t.name), + )) + .toList(), + onChanged: (v) => setState(() => _agentType = v!), + ), + const SizedBox(height: 16), + + // Bridge URL + Row( + children: [ + Expanded( + child: TextFormField( + controller: _bridgeUrlController, + decoration: const InputDecoration( + labelText: 'Bridge URL', + hintText: 'wss://host:3000', + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + keyboardType: TextInputType.url, + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Scan QR', + icon: const Icon(Icons.qr_code_scanner), + onPressed: _scanQr, + ), + ], + ), + const SizedBox(height: 16), + + // Auth token (obscured) + TextFormField( + controller: _tokenController, + obscureText: _obscureToken, + decoration: InputDecoration( + labelText: 'Auth Token', + suffixIcon: IconButton( + icon: Icon(_obscureToken + ? Icons.visibility_off + : Icons.visibility), + onPressed: () => + setState(() => _obscureToken = !_obscureToken), + ), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 24), + + // Test connection button + OutlinedButton.icon( + onPressed: _isTesting ? null : _testConnection, + icon: _isTesting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_tethering, size: 18), + label: const Text('Test Connection'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// QR scanner page +// --------------------------------------------------------------------------- + +class _QrScanPage extends StatefulWidget { + const _QrScanPage(); + + @override + State<_QrScanPage> createState() => _QrScanPageState(); +} + +class _QrScanPageState extends State<_QrScanPage> { + bool _scanned = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Scan QR Code')), + body: MobileScanner( + onDetect: (capture) { + if (_scanned) return; + final barcode = capture.barcodes.firstOrNull; + final value = barcode?.rawValue; + if (value != null && value.isNotEmpty) { + _scanned = true; + Navigator.of(context).pop(value); + } + }, + ), + ); + } +} diff --git a/apps/mobile/lib/features/agents/presentation/screens/agent_list_screen.dart b/apps/mobile/lib/features/agents/presentation/screens/agent_list_screen.dart new file mode 100644 index 0000000..74b547f --- /dev/null +++ b/apps/mobile/lib/features/agents/presentation/screens/agent_list_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/providers/agent_provider.dart'; +import '../widgets/agent_card.dart'; + +/// Lists all configured agents with swipe-to-delete and an add FAB. +class AgentListScreen extends ConsumerStatefulWidget { + const AgentListScreen({super.key}); + + @override + ConsumerState createState() => _AgentListScreenState(); +} + +class _AgentListScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(agentsProvider.notifier).load(); + }); + } + + Future _delete(String id, String name) async { + await ref.read(agentsProvider.notifier).delete(id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$name removed'), + action: SnackBarAction( + label: 'Undo', + onPressed: () { + // Re-adding would require keeping the deleted model around; + // for now just refresh — a full undo is a future enhancement. + ref.read(agentsProvider.notifier).load(); + }, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final agentsAsync = ref.watch(agentsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Agents')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/home/agents/config'), + icon: const Icon(Icons.add), + label: const Text('Add Agent'), + ), + body: agentsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (agents) { + if (agents.isEmpty) { + return const _EmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 80), + itemCount: agents.length, + itemBuilder: (context, index) { + final agent = agents[index]; + return Dismissible( + key: Key(agent.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + color: const Color(0xFFF44747), + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete_outline, + color: Colors.white), + ), + onDismissed: (_) => _delete(agent.id, agent.displayName), + child: AgentCard( + agent: agent, + onTap: () => + context.push('/home/agents/config', extra: agent), + ), + ); + }, + ); + }, + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.smart_toy_outlined, size: 64, color: Color(0xFF9E9E9E)), + SizedBox(height: 16), + Text( + 'No agents configured', + style: TextStyle(fontSize: 16, color: Color(0xFF9E9E9E)), + ), + SizedBox(height: 8), + Text( + 'Tap + Add Agent to get started.', + style: TextStyle(fontSize: 13, color: Color(0xFF666666)), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/agents/presentation/widgets/agent_card.dart b/apps/mobile/lib/features/agents/presentation/widgets/agent_card.dart new file mode 100644 index 0000000..897bfc0 --- /dev/null +++ b/apps/mobile/lib/features/agents/presentation/widgets/agent_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/agent_models.dart'; +import '../../../../shared/utils/date_formatter.dart'; + +/// Card for a configured [AgentConfig] in the agent list. +class AgentCard extends StatelessWidget { + final AgentConfig agent; + final VoidCallback onTap; + + const AgentCard({super.key, required this.agent, required this.onTap}); + + IconData _iconForType(AgentType type) { + return switch (type) { + AgentType.claudeCode => Icons.auto_awesome, + AgentType.openCode => Icons.code, + AgentType.aider => Icons.terminal, + AgentType.goose => Icons.rocket_launch_outlined, + AgentType.custom => Icons.settings_input_component, + }; + } + + Color _statusColor(AgentConnectionStatus status) { + return switch (status) { + AgentConnectionStatus.connected => const Color(0xFF4CAF50), + AgentConnectionStatus.inactive => const Color(0xFFFF9800), + AgentConnectionStatus.disconnected => const Color(0xFF9E9E9E), + }; + } + + @override + Widget build(BuildContext context) { + final statusColor = _statusColor(agent.status); + final lastConnected = agent.lastConnectedAt != null + ? DateFormatter.formatRelative(agent.lastConnectedAt!) + : 'Never'; + + // Truncate long bridge URLs. + final bridgeDisplay = agent.bridgeUrl.length > 40 + ? '${agent.bridgeUrl.substring(0, 37)}…' + : agent.bridgeUrl; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF252526), + child: Icon( + _iconForType(agent.type), + size: 18, + color: const Color(0xFF569CD6), + ), + ), + title: Text( + agent.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFFD4D4D4), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bridgeDisplay, + style: const TextStyle( + fontSize: 11, + fontFamily: 'JetBrainsMono', + color: Color(0xFF9E9E9E), + ), + ), + Text( + 'Last connected: $lastConnected', + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9E9E9E), + ), + ), + ], + ), + trailing: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + isThreeLine: true, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/approvals/domain/providers/approval_provider.dart b/apps/mobile/lib/features/approvals/domain/providers/approval_provider.dart new file mode 100644 index 0000000..60713f3 --- /dev/null +++ b/apps/mobile/lib/features/approvals/domain/providers/approval_provider.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/message_models.dart'; +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/providers/database_provider.dart'; +import '../../../../core/providers/websocket_provider.dart'; +import '../../../../core/storage/database.dart'; +import '../../../../core/storage/tables/approvals_table.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +// --------------------------------------------------------------------------- +// Pending approvals (in-memory list) +// --------------------------------------------------------------------------- + +class PendingApprovalsNotifier extends StateNotifier> { + PendingApprovalsNotifier(this._ref) : super([]) { + _listen(); + } + + final Ref _ref; + StreamSubscription? _sub; + + void _listen() { + final service = _ref.read(webSocketServiceProvider); + _sub = service.messages.listen((msg) { + if (msg.type == BridgeMessageType.approvalRequired) { + try { + final toolCall = _parseToolCall(msg.payload); + state = [...state, toolCall]; + } catch (_) { + // Malformed payload — ignore. + } + } + }); + } + + ToolCall _parseToolCall(Map payload) { + return ToolCall( + id: payload['tool_call_id'] as String? ?? _uuid.v4(), + sessionId: payload['session_id'] as String? ?? '', + tool: payload['tool'] as String? ?? 'unknown', + params: (payload['params'] as Map?) ?? {}, + description: payload['description'] as String?, + reasoning: payload['reasoning'] as String?, + riskLevel: _parseRisk(payload['risk_level'] as String?), + decision: ApprovalDecision.pending, + createdAt: DateTime.now(), + ); + } + + RiskLevel _parseRisk(String? value) { + return switch (value) { + 'medium' => RiskLevel.medium, + 'high' => RiskLevel.high, + 'critical' => RiskLevel.critical, + _ => RiskLevel.low, + }; + } + + Future approve(String sessionId, String toolCallId) async { + await _sendDecision(sessionId, toolCallId, 'approved'); + await _persist(toolCallId, ApprovalDecision.approved); + _remove(toolCallId); + } + + Future reject(String sessionId, String toolCallId) async { + await _sendDecision(sessionId, toolCallId, 'rejected'); + await _persist(toolCallId, ApprovalDecision.rejected); + _remove(toolCallId); + } + + Future modify( + String sessionId, + String toolCallId, + Map modifications, + ) async { + final service = _ref.read(webSocketServiceProvider); + service.send(BridgeMessage.approvalResponse( + sessionId: sessionId, + toolCallId: toolCallId, + decision: 'modified', + modifications: modifications, + )); + await _persist(toolCallId, ApprovalDecision.modified, + modifications: jsonEncode(modifications)); + _remove(toolCallId); + } + + Future _sendDecision( + String sessionId, String toolCallId, String decision) async { + final service = _ref.read(webSocketServiceProvider); + service.send(BridgeMessage.approvalResponse( + sessionId: sessionId, + toolCallId: toolCallId, + decision: decision, + )); + } + + Future _persist( + String toolCallId, + ApprovalDecision decision, { + String? modifications, + }) async { + final db = _ref.read(databaseProvider); + final pending = state.where((t) => t.id == toolCallId).firstOrNull; + if (pending == null) return; + + await db.into(db.approvals).insertOnConflictUpdate( + ApprovalsCompanion( + id: Value(pending.id), + sessionId: Value(pending.sessionId), + tool: Value(pending.tool), + description: Value(pending.description ?? ''), + params: Value(jsonEncode(pending.params)), + reasoning: Value(pending.reasoning), + riskLevel: Value(pending.riskLevel.name), + decision: Value(decision.name), + modifications: Value(modifications), + createdAt: Value(pending.createdAt), + decidedAt: Value(DateTime.now()), + ), + ); + } + + void _remove(String toolCallId) { + state = state.where((t) => t.id != toolCallId).toList(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} + +final pendingApprovalsProvider = + StateNotifierProvider>((ref) { + return PendingApprovalsNotifier(ref); +}); + +// --------------------------------------------------------------------------- +// Approval history (stream from DB) +// --------------------------------------------------------------------------- + +final approvalHistoryProvider = StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return db.select(db.approvals) + .watch() + .map((rows) => rows.map(_rowToToolCall).toList()); +}); + +ToolCall _rowToToolCall(dynamic row) { + Map params = {}; + try { + params = jsonDecode(row.params as String) as Map; + } catch (_) {} + + Map? result; + if (row.result != null) { + try { + result = jsonDecode(row.result as String) as Map; + } catch (_) {} + } + + return ToolCall( + id: row.id as String, + sessionId: row.sessionId as String, + tool: row.tool as String, + params: params, + description: row.description as String?, + reasoning: row.reasoning as String?, + riskLevel: _parseRiskLevel(row.riskLevel as String), + decision: _parseDecision(row.decision as String), + modifications: row.modifications as String?, + result: result, + createdAt: row.createdAt as DateTime, + decidedAt: row.decidedAt as DateTime?, + ); +} + +RiskLevel _parseRiskLevel(String v) { + return RiskLevel.values.firstWhere( + (e) => e.name == v, + orElse: () => RiskLevel.low, + ); +} + +ApprovalDecision _parseDecision(String v) { + return ApprovalDecision.values.firstWhere( + (e) => e.name == v, + orElse: () => ApprovalDecision.pending, + ); +} diff --git a/apps/mobile/lib/features/approvals/presentation/screens/approval_detail_screen.dart b/apps/mobile/lib/features/approvals/presentation/screens/approval_detail_screen.dart new file mode 100644 index 0000000..6d6018e --- /dev/null +++ b/apps/mobile/lib/features/approvals/presentation/screens/approval_detail_screen.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/models/message_models.dart'; +import '../../domain/providers/approval_provider.dart'; +import '../widgets/modification_editor.dart'; + +/// Full-screen detail view for a pending [ToolCall] approval. +/// +/// Route: `/approval/:id` +class ApprovalDetailScreen extends ConsumerStatefulWidget { + final String toolCallId; + + const ApprovalDetailScreen({super.key, required this.toolCallId}); + + @override + ConsumerState createState() => + _ApprovalDetailScreenState(); +} + +class _ApprovalDetailScreenState + extends ConsumerState { + final _modController = TextEditingController(); + bool _showModEditor = false; + + @override + void dispose() { + _modController.dispose(); + super.dispose(); + } + + ToolCall? _findToolCall(List list) { + try { + return list.firstWhere((t) => t.id == widget.toolCallId); + } catch (_) { + return null; + } + } + + Color _riskColor(RiskLevel level) { + return switch (level) { + RiskLevel.low => const Color(0xFF4CAF50), + RiskLevel.medium => const Color(0xFFFF9800), + RiskLevel.high => const Color(0xFFF44747), + RiskLevel.critical => const Color(0xFF8B0000), + }; + } + + void _approve(ToolCall toolCall) { + ref + .read(pendingApprovalsProvider.notifier) + .approve(toolCall.sessionId, toolCall.id); + context.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Approved')), + ); + } + + void _reject(ToolCall toolCall) { + ref + .read(pendingApprovalsProvider.notifier) + .reject(toolCall.sessionId, toolCall.id); + context.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Rejected')), + ); + } + + void _submitModifications(ToolCall toolCall) { + final text = _modController.text.trim(); + if (text.isEmpty) return; + ref.read(pendingApprovalsProvider.notifier).modify( + toolCall.sessionId, + toolCall.id, + {'instructions': text}, + ); + context.pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Modifications submitted')), + ); + } + + @override + Widget build(BuildContext context) { + final pending = ref.watch(pendingApprovalsProvider); + final toolCall = _findToolCall(pending); + + if (toolCall == null) { + return Scaffold( + appBar: AppBar(title: const Text('Approval')), + body: const Center(child: Text('Approval not found or already decided.')), + ); + } + + final riskColor = _riskColor(toolCall.riskLevel); + + return Scaffold( + appBar: AppBar(title: Text(toolCall.tool)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Risk badge + description + Row( + children: [ + _RiskBadge(level: toolCall.riskLevel, color: riskColor), + const SizedBox(width: 12), + Expanded( + child: Text( + toolCall.description ?? 'No description provided.', + style: const TextStyle( + fontSize: 14, + color: Color(0xFFD4D4D4), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Parameters expandable card + _ExpandableSection( + title: 'Parameters', + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0D1117), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + const JsonEncoder.withIndent(' ') + .convert(toolCall.params), + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 12, + color: Color(0xFFD4D4D4), + ), + ), + ), + ), + + // Reasoning expandable card (if available) + if (toolCall.reasoning != null) ...[ + const SizedBox(height: 8), + _ExpandableSection( + title: 'Reasoning', + child: Text( + toolCall.reasoning!, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF9E9E9E), + ), + ), + ), + ], + + // Modification editor (shown after tapping Modify) + if (_showModEditor) ...[ + const SizedBox(height: 16), + ModificationEditor( + controller: _modController, + onSubmit: () => _submitModifications(toolCall), + ), + ], + + const SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => _approve(toolCall), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Approve'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () => _reject(toolCall), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF44747), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Reject'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () => setState(() { + _showModEditor = !_showModEditor; + }), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF9800), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Modify'), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _RiskBadge extends StatelessWidget { + final RiskLevel level; + final Color color; + + const _RiskBadge({required this.level, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + level.name.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } +} + +class _ExpandableSection extends StatefulWidget { + final String title; + final Widget child; + + const _ExpandableSection({required this.title, required this.child}); + + @override + State<_ExpandableSection> createState() => _ExpandableSectionState(); +} + +class _ExpandableSectionState extends State<_ExpandableSection> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFFD4D4D4), + ), + ), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: const Color(0xFF9E9E9E), + ), + ], + ), + ), + ), + if (_expanded) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/approvals/presentation/screens/approvals_screen.dart b/apps/mobile/lib/features/approvals/presentation/screens/approvals_screen.dart new file mode 100644 index 0000000..aad101d --- /dev/null +++ b/apps/mobile/lib/features/approvals/presentation/screens/approvals_screen.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/models/message_models.dart'; +import '../../domain/providers/approval_provider.dart'; +import '../widgets/approval_card.dart'; + +/// Two-tab screen: "Pending" approvals and "History" of past decisions. +class ApprovalsScreen extends ConsumerWidget { + const ApprovalsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Approvals'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Pending'), + Tab(text: 'History'), + ], + ), + ), + body: const TabBarView( + children: [ + _PendingTab(), + _HistoryTab(), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Pending tab +// --------------------------------------------------------------------------- + +class _PendingTab extends ConsumerWidget { + const _PendingTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pending = ref.watch(pendingApprovalsProvider); + + if (pending.isEmpty) { + return const _EmptyState( + icon: Icons.check_circle_outline, + title: 'No pending approvals', + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: pending.length, + itemBuilder: (context, index) { + final toolCall = pending[index]; + return ApprovalCard( + toolCall: toolCall, + onApprove: () => ref + .read(pendingApprovalsProvider.notifier) + .approve(toolCall.sessionId, toolCall.id), + onReject: () => ref + .read(pendingApprovalsProvider.notifier) + .reject(toolCall.sessionId, toolCall.id), + onTap: () => context.push('/approval/${toolCall.id}'), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// History tab +// --------------------------------------------------------------------------- + +class _HistoryTab extends ConsumerWidget { + const _HistoryTab(); + + Color _decisionColor(ApprovalDecision decision) { + return switch (decision) { + ApprovalDecision.approved => const Color(0xFF4CAF50), + ApprovalDecision.rejected => const Color(0xFFF44747), + ApprovalDecision.modified => const Color(0xFFFF9800), + ApprovalDecision.pending => const Color(0xFF569CD6), + }; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(approvalHistoryProvider); + + return historyAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (history) { + if (history.isEmpty) { + return const _EmptyState( + icon: Icons.history, + title: 'No history yet', + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + final color = _decisionColor(item.decision); + return ListTile( + dense: true, + leading: const Icon(Icons.build_outlined, + size: 18, color: Color(0xFF9E9E9E)), + title: Text( + item.tool, + style: const TextStyle(fontSize: 13), + ), + subtitle: item.description != null + ? Text( + item.description!, + style: + const TextStyle(fontSize: 11, color: Color(0xFF9E9E9E)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + item.decision.name.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +class _EmptyState extends StatelessWidget { + final IconData icon; + final String title; + + const _EmptyState({required this.icon, required this.title}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: const Color(0xFF9E9E9E)), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle(fontSize: 15, color: Color(0xFF9E9E9E)), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/approvals/presentation/widgets/approval_card.dart b/apps/mobile/lib/features/approvals/presentation/widgets/approval_card.dart new file mode 100644 index 0000000..954c946 --- /dev/null +++ b/apps/mobile/lib/features/approvals/presentation/widgets/approval_card.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/message_models.dart'; + +/// Card widget representing a pending tool-call approval. +class ApprovalCard extends StatelessWidget { + final ToolCall toolCall; + final VoidCallback onApprove; + final VoidCallback onReject; + final VoidCallback onTap; + + const ApprovalCard({ + super.key, + required this.toolCall, + required this.onApprove, + required this.onReject, + required this.onTap, + }); + + Color _riskColor(RiskLevel level) { + return switch (level) { + RiskLevel.low => const Color(0xFF4CAF50), + RiskLevel.medium => const Color(0xFFFF9800), + RiskLevel.high => const Color(0xFFF44747), + RiskLevel.critical => const Color(0xFF8B0000), + }; + } + + @override + Widget build(BuildContext context) { + final riskColor = _riskColor(toolCall.riskLevel); + + return Semantics( + label: 'Tool approval: ${toolCall.tool}, risk: ${toolCall.riskLevel.name}', + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: tool icon + name + risk badge + Row( + children: [ + const Icon(Icons.build_outlined, + size: 16, color: Color(0xFF9E9E9E)), + const SizedBox(width: 6), + Expanded( + child: Text( + toolCall.tool, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFFD4D4D4), + ), + ), + ), + _RiskBadge(level: toolCall.riskLevel, color: riskColor), + ], + ), + if (toolCall.description != null) ...[ + const SizedBox(height: 6), + Text( + toolCall.description!, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF9E9E9E), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 10), + // Action buttons + Row( + children: [ + Expanded( + child: Semantics( + label: 'Approve tool call', + button: true, + child: OutlinedButton( + onPressed: onApprove, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF4CAF50), + side: const BorderSide(color: Color(0xFF4CAF50)), + padding: const EdgeInsets.symmetric(vertical: 6), + ), + child: const Text('Approve', style: TextStyle(fontSize: 12)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Semantics( + label: 'Reject tool call', + button: true, + child: OutlinedButton( + onPressed: onReject, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFF44747), + side: const BorderSide(color: Color(0xFFF44747)), + padding: const EdgeInsets.symmetric(vertical: 6), + ), + child: const Text('Reject', style: TextStyle(fontSize: 12)), + ), + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + ), + child: const Text('Details', style: TextStyle(fontSize: 12)), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _RiskBadge extends StatelessWidget { + final RiskLevel level; + final Color color; + + const _RiskBadge({required this.level, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + level.name.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/approvals/presentation/widgets/modification_editor.dart b/apps/mobile/lib/features/approvals/presentation/widgets/modification_editor.dart new file mode 100644 index 0000000..1ca8347 --- /dev/null +++ b/apps/mobile/lib/features/approvals/presentation/widgets/modification_editor.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +/// A labeled text field + submit button used on the approval detail screen +/// when the user chooses to modify a tool call. +class ModificationEditor extends StatelessWidget { + final TextEditingController controller; + final VoidCallback onSubmit; + + const ModificationEditor({ + super.key, + required this.controller, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Modifications', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF9E9E9E), + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + hintText: 'Describe your modifications…', + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF9800), + foregroundColor: Colors.white, + ), + child: const Text('Submit Modifications'), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/chat/domain/providers/chat_provider.dart b/apps/mobile/lib/features/chat/domain/providers/chat_provider.dart new file mode 100644 index 0000000..7695a3f --- /dev/null +++ b/apps/mobile/lib/features/chat/domain/providers/chat_provider.dart @@ -0,0 +1,684 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../../core/models/message_models.dart'; +import '../../../../core/models/session_models.dart'; +import '../../../../core/network/connection_state.dart'; +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/providers/database_provider.dart'; +import '../../../../core/providers/sync_queue_provider.dart'; +import '../../../../core/providers/websocket_provider.dart'; +import '../../../../core/storage/database.dart' as db_lib; +import 'session_provider.dart'; + +part 'chat_provider.g.dart'; + +const _uuid = Uuid(); +const _messageOperation = 'message'; +const _sessionStartOperation = 'session_start'; +const _sessionEndOperation = 'session_end'; + +// --------------------------------------------------------------------------- +// Streaming message buffer: sessionId → current streaming text +// --------------------------------------------------------------------------- + +final streamingMessageProvider = + StateProvider>((ref) => {}); + +// --------------------------------------------------------------------------- +// Messages stream for a session +// --------------------------------------------------------------------------- + +@riverpod +Stream> messages(Ref ref, String sessionId) { + final db = ref.watch(databaseProvider); + return db.messageDao.watchMessagesForSession(sessionId).map( + (rows) => rows.map(_rowToDomainMessage).toList(), + ); +} + +Message _rowToDomainMessage(db_lib.Message row) { + List parts = []; + if (row.metadata != null) { + try { + final decoded = jsonDecode(row.metadata!) as Map; + final partsJson = decoded['parts'] as List?; + if (partsJson != null) { + parts = partsJson + .map((p) => MessagePart.fromJson(p as Map)) + .toList(); + } + } catch (_) {} + } + final role = MessageRole.values.firstWhere( + (e) => e.name == row.role, + orElse: () => MessageRole.agent, + ); + final type = MessageType.values.firstWhere( + (e) => e.name == row.messageType, + orElse: () => MessageType.text, + ); + return Message( + id: row.id, + sessionId: row.sessionId, + role: role, + content: row.content, + type: type, + parts: parts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + synced: row.synced, + ); +} + +// --------------------------------------------------------------------------- +// Chat notifier +// --------------------------------------------------------------------------- + +@riverpod +class ChatNotifier extends _$ChatNotifier { + StreamSubscription? _messageSubscription; + StreamSubscription? _statusSubscription; + + @override + Future build() async { + final service = ref.watch(webSocketServiceProvider); + final syncQueue = ref.watch(syncQueueServiceProvider); + + final cachedAckPayload = service.lastConnectionAckPayload; + if (cachedAckPayload != null) { + await _syncActiveSessions(cachedAckPayload); + } + if (service.currentStatus == ConnectionStatus.connected) { + await syncQueue.flush(service); + } + + _messageSubscription = service.messages.listen(_handleBridgeMessage); + _statusSubscription = service.connectionStatus.listen((status) async { + if (status == ConnectionStatus.connected) { + await syncQueue.flush(service); + final ackPayload = service.lastConnectionAckPayload; + if (ackPayload != null) { + await _syncActiveSessions(ackPayload); + } + } + }); + + ref.onDispose(() { + _messageSubscription?.cancel(); + _statusSubscription?.cancel(); + }); + } + + void _handleBridgeMessage(BridgeMessage message) { + final payload = message.payload; + + switch (message.type) { + case BridgeMessageType.connectionAck: + unawaited(_syncActiveSessions(payload)); + break; + case BridgeMessageType.sessionReady: + unawaited(_persistSessionReady(payload)); + break; + case BridgeMessageType.streamStart: + _setStreaming(_stringValue(payload['session_id']), ''); + break; + case BridgeMessageType.streamChunk: + _appendStreaming( + _stringValue(payload['session_id']), + _stringValue(payload['content']), + ); + break; + case BridgeMessageType.streamEnd: + unawaited(_finalizeStreaming(_stringValue(payload['session_id']))); + break; + case BridgeMessageType.approvalRequired: + unawaited(_persistApprovalRequired(payload)); + break; + case BridgeMessageType.toolResult: + unawaited(_persistToolResult(payload)); + break; + case BridgeMessageType.claudeEvent: + unawaited(_handleClaudeEvent(payload)); + break; + case BridgeMessageType.sessionEnd: + unawaited(_markSessionClosed(_stringValue(payload['session_id']))); + break; + default: + break; + } + } + + Future _syncActiveSessions(Map payload) async { + final rawSessions = payload['active_sessions'] as List? ?? []; + for (final rawSession in rawSessions.whereType>()) { + await _upsertSession( + sessionId: _stringValue(rawSession['session_id']), + agentType: _stringValue(rawSession['agent'], fallback: 'claude-code'), + title: _stringValue(rawSession['title']), + workingDirectory: _stringValue(rawSession['working_directory']), + status: _sessionStatusFromRemote(rawSession['status'] as String?), + synced: true, + ); + } + ref.invalidate(activeSessionsProvider); + } + + void _setStreaming(String sessionId, String text) { + if (sessionId.isEmpty) { + return; + } + + final current = Map.from( + ref.read(streamingMessageProvider), + ); + current[sessionId] = text; + ref.read(streamingMessageProvider.notifier).state = current; + } + + void _appendStreaming(String sessionId, String chunk) { + if (sessionId.isEmpty || chunk.isEmpty) { + return; + } + + final current = Map.from( + ref.read(streamingMessageProvider), + ); + current[sessionId] = (current[sessionId] ?? '') + chunk; + ref.read(streamingMessageProvider.notifier).state = current; + } + + Future _finalizeStreaming(String sessionId) async { + if (sessionId.isEmpty) { + return; + } + + final current = Map.from( + ref.read(streamingMessageProvider), + ); + final text = current.remove(sessionId) ?? ''; + ref.read(streamingMessageProvider.notifier).state = current; + + if (text.isEmpty) { + return; + } + + await _ensureSessionExists(sessionId); + final now = DateTime.now().toUtc(); + await _insertMessage( + Message( + id: _uuid.v4(), + sessionId: sessionId, + role: MessageRole.agent, + content: text, + type: MessageType.text, + parts: [MessagePart.text(content: text)], + createdAt: now, + updatedAt: now, + ), + ); + } + + Future _persistSessionReady(Map payload) async { + final sessionId = _stringValue(payload['session_id']); + if (sessionId.isEmpty) { + return; + } + + await _upsertSession( + sessionId: sessionId, + agentType: _stringValue(payload['agent'], fallback: 'claude-code'), + title: _titleFromWorkingDirectory( + _stringValue(payload['working_directory']), + ), + workingDirectory: _stringValue(payload['working_directory']), + branch: payload['branch'] as String?, + status: SessionStatus.active, + synced: true, + ); + ref.invalidate(activeSessionsProvider); + } + + Future _persistApprovalRequired(Map payload) async { + final sessionId = _stringValue(payload['session_id']); + if (sessionId.isEmpty) { + return; + } + + await _ensureSessionExists(sessionId); + final now = DateTime.now().toUtc(); + await _insertMessage( + Message( + id: _uuid.v4(), + sessionId: sessionId, + role: MessageRole.agent, + content: _stringValue(payload['description']), + type: MessageType.toolCall, + parts: [ + MessagePart.toolUse( + tool: _stringValue(payload['tool'], fallback: 'unknown_tool'), + params: _mapValue(payload['params']), + id: _stringValue(payload['tool_call_id']), + ), + ], + metadata: { + 'description': _stringValue(payload['description']), + 'risk_level': _stringValue(payload['risk_level']), + 'source': _stringValue(payload['source']), + }, + createdAt: now, + updatedAt: now, + ), + ); + } + + Future _persistToolResult(Map payload) async { + final sessionId = _stringValue(payload['session_id']); + if (sessionId.isEmpty) { + return; + } + + await _ensureSessionExists(sessionId); + final resultMap = _mapValue(payload['result']); + final toolName = _stringValue(payload['tool'], fallback: 'unknown_tool'); + final metadata = {'tool': toolName}; + final diff = resultMap['diff'] as String?; + if (diff != null && diff.isNotEmpty) { + metadata['diff'] = diff; + } + + final now = DateTime.now().toUtc(); + await _insertMessage( + Message( + id: _uuid.v4(), + sessionId: sessionId, + role: MessageRole.agent, + content: _stringValue(resultMap['content']), + type: MessageType.toolResult, + parts: [ + MessagePart.toolResult( + toolCallId: _stringValue(payload['tool_call_id']), + result: ToolResult( + success: resultMap['success'] as bool? ?? true, + content: _stringValue(resultMap['content']), + metadata: metadata, + error: resultMap['error'] as String?, + durationMs: resultMap['duration_ms'] as int?, + ), + ), + ], + createdAt: now, + updatedAt: now, + ), + ); + } + + Future _handleClaudeEvent(Map payload) async { + final eventType = _stringValue(payload['event_type']); + final sessionId = _stringValue(payload['session_id']); + final eventPayload = _mapValue(payload['payload']); + + if (sessionId.isEmpty || eventType.isEmpty) { + return; + } + + switch (eventType) { + case 'SessionStart': + await _ensureSessionExists( + sessionId, + workingDirectory: _stringValue(eventPayload['working_directory']), + title: _stringValue(eventPayload['title']), + ); + ref.invalidate(activeSessionsProvider); + break; + case 'UserPromptSubmit': + final content = _stringValue( + eventPayload['prompt'], + fallback: _stringValue( + eventPayload['message'], + fallback: _stringValue(eventPayload['text']), + ), + ); + if (content.isEmpty) { + break; + } + await _ensureSessionExists(sessionId); + final now = DateTime.now().toUtc(); + await _insertMessage( + Message( + id: _uuid.v4(), + sessionId: sessionId, + role: MessageRole.user, + content: content, + type: MessageType.text, + parts: [MessagePart.text(content: content)], + createdAt: now, + updatedAt: now, + ), + ); + break; + case 'SessionEnd': + case 'Stop': + await _markSessionClosed(sessionId); + break; + default: + break; + } + } + + Future _ensureSessionExists( + String sessionId, { + String? workingDirectory, + String? title, + }) async { + final db = ref.read(databaseProvider); + final existing = await db.sessionDao.getSession(sessionId); + if (existing != null) { + return; + } + + await _upsertSession( + sessionId: sessionId, + agentType: 'claude-code', + title: title ?? _titleFromWorkingDirectory(workingDirectory ?? ''), + workingDirectory: workingDirectory ?? '', + status: SessionStatus.active, + synced: true, + ); + } + + Future _upsertSession({ + required String sessionId, + required String agentType, + required String title, + required String workingDirectory, + String? branch, + required SessionStatus status, + required bool synced, + }) async { + if (sessionId.isEmpty) { + return; + } + + final db = ref.read(databaseProvider); + final existing = await db.sessionDao.getSession(sessionId); + final now = DateTime.now().toUtc(); + + await db.sessionDao.upsertSession( + db_lib.SessionsCompanion( + id: Value(sessionId), + agentType: Value(existing?.agentType ?? agentType), + agentId: Value(existing?.agentId), + title: Value( + _coalesceNonEmpty( + existing?.title, + title, + _titleFromWorkingDirectory(workingDirectory), + ), + ), + workingDirectory: Value( + _coalesceNonEmpty(existing?.workingDirectory, workingDirectory), + ), + branch: Value(branch ?? existing?.branch), + status: Value(status.name), + createdAt: Value(existing?.createdAt ?? now), + lastMessageAt: Value(existing?.lastMessageAt), + updatedAt: Value(now), + synced: Value((existing?.synced ?? false) || synced), + ), + ); + } + + Future _touchSession(String sessionId) async { + final db = ref.read(databaseProvider); + final existing = await db.sessionDao.getSession(sessionId); + if (existing == null) { + return; + } + + final now = DateTime.now().toUtc(); + await db.sessionDao.upsertSession( + db_lib.SessionsCompanion( + id: Value(existing.id), + agentType: Value(existing.agentType), + agentId: Value(existing.agentId), + title: Value(existing.title), + workingDirectory: Value(existing.workingDirectory), + branch: Value(existing.branch), + status: Value(existing.status), + createdAt: Value(existing.createdAt), + lastMessageAt: Value(now), + updatedAt: Value(now), + synced: Value(existing.synced), + ), + ); + ref.invalidate(activeSessionsProvider); + } + + Future _markSessionClosed(String sessionId) async { + if (sessionId.isEmpty) { + return; + } + + final db = ref.read(databaseProvider); + final existing = await db.sessionDao.getSession(sessionId); + if (existing == null) { + return; + } + + final now = DateTime.now().toUtc(); + await db.sessionDao.upsertSession( + db_lib.SessionsCompanion( + id: Value(existing.id), + agentType: Value(existing.agentType), + agentId: Value(existing.agentId), + title: Value(existing.title), + workingDirectory: Value(existing.workingDirectory), + branch: Value(existing.branch), + status: Value(SessionStatus.closed.name), + createdAt: Value(existing.createdAt), + lastMessageAt: Value(existing.lastMessageAt), + updatedAt: Value(now), + synced: Value(existing.synced), + ), + ); + ref.invalidate(activeSessionsProvider); + } + + Future _insertMessage(Message message) async { + final db = ref.read(databaseProvider); + await db.messageDao.insertMessage(_toCompanion(message)); + await _touchSession(message.sessionId); + } + + // ---------- Public actions ----------------------------------------------- + + Future sendMessage(String sessionId, String content) async { + final trimmedContent = content.trim(); + if (trimmedContent.isEmpty) { + return; + } + + await _ensureSessionExists(sessionId); + + final service = ref.read(webSocketServiceProvider); + final syncQueue = ref.read(syncQueueServiceProvider); + final now = DateTime.now().toUtc(); + final localMessageId = _uuid.v4(); + final outgoingMessage = BridgeMessage.message( + sessionId: sessionId, + content: trimmedContent, + ); + final sent = service.send(outgoingMessage); + + await _insertMessage( + Message( + id: localMessageId, + sessionId: sessionId, + role: MessageRole.user, + content: trimmedContent, + type: MessageType.text, + parts: [MessagePart.text(content: trimmedContent)], + createdAt: now, + updatedAt: now, + synced: sent, + ), + ); + + if (!sent) { + await syncQueue.enqueue( + _messageOperation, + { + 'session_id': sessionId, + 'content': trimmedContent, + 'role': 'user', + 'local_message_id': localMessageId, + }, + sessionId: sessionId, + ); + } + } + + Future startSession(String agent, String workingDir) async { + final normalizedDirectory = workingDir.trim(); + if (normalizedDirectory.isEmpty) { + throw ArgumentError('Working directory is required.'); + } + + final sessionId = _uuid.v4(); + final service = ref.read(webSocketServiceProvider); + final syncQueue = ref.read(syncQueueServiceProvider); + final sent = service.send( + BridgeMessage.sessionStart( + agent: agent, + sessionId: sessionId, + workingDirectory: normalizedDirectory, + ), + ); + + await _upsertSession( + sessionId: sessionId, + agentType: agent, + title: _titleFromWorkingDirectory(normalizedDirectory), + workingDirectory: normalizedDirectory, + status: SessionStatus.active, + synced: sent, + ); + + if (!sent) { + await syncQueue.enqueue( + _sessionStartOperation, + { + 'agent': agent, + 'session_id': sessionId, + 'working_directory': normalizedDirectory, + 'resume': false, + }, + sessionId: sessionId, + ); + } + + ref.invalidate(activeSessionsProvider); + return sessionId; + } + + Future endSession(String sessionId) async { + if (sessionId.isEmpty) { + return; + } + + final service = ref.read(webSocketServiceProvider); + final syncQueue = ref.read(syncQueueServiceProvider); + final sent = service.send( + BridgeMessage.sessionEnd(sessionId: sessionId), + ); + + if (!sent) { + await syncQueue.enqueue( + _sessionEndOperation, + {'session_id': sessionId, 'reason': 'user_request'}, + sessionId: sessionId, + ); + } + + await _markSessionClosed(sessionId); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +db_lib.MessagesCompanion _toCompanion(Message msg) { + final metadata = jsonEncode({ + 'parts': msg.parts.map((p) => p.toJson()).toList(), + if (msg.metadata != null) ...msg.metadata!, + }); + return db_lib.MessagesCompanion( + id: Value(msg.id), + sessionId: Value(msg.sessionId), + role: Value(msg.role.name), + content: Value(msg.content), + messageType: Value(msg.type.name), + metadata: Value(metadata), + createdAt: Value(msg.createdAt), + updatedAt: Value(msg.updatedAt ?? msg.createdAt), + synced: Value(msg.synced), + ); +} + +String _stringValue(Object? value, {String fallback = ''}) { + if (value is String && value.isNotEmpty) { + return value; + } + return fallback; +} + +Map _mapValue(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.map( + (key, value) => MapEntry(key.toString(), value), + ); + } + return {}; +} + +String _titleFromWorkingDirectory(String workingDirectory) { + if (workingDirectory.isEmpty) { + return 'Claude Code'; + } + + final normalized = workingDirectory.replaceAll('\\', '/'); + final segments = normalized.split('/').where((segment) => segment.isNotEmpty); + return segments.isEmpty ? workingDirectory : segments.last; +} + +String _coalesceNonEmpty(String? first, String? second, + [String fallback = '']) { + if (first != null && first.isNotEmpty) { + return first; + } + if (second != null && second.isNotEmpty) { + return second; + } + return fallback; +} + +SessionStatus _sessionStatusFromRemote(String? status) { + switch (status) { + case 'closed': + return SessionStatus.closed; + case 'paused': + return SessionStatus.paused; + default: + return SessionStatus.active; + } +} diff --git a/apps/mobile/lib/features/chat/domain/providers/chat_provider.g.dart b/apps/mobile/lib/features/chat/domain/providers/chat_provider.g.dart new file mode 100644 index 0000000..ab9de01 --- /dev/null +++ b/apps/mobile/lib/features/chat/domain/providers/chat_provider.g.dart @@ -0,0 +1,175 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$messagesHash() => r'df25cd2487c082acbc3600ec8bc8a1448f4b96b0'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [messages]. +@ProviderFor(messages) +const messagesProvider = MessagesFamily(); + +/// See also [messages]. +class MessagesFamily extends Family>> { + /// See also [messages]. + const MessagesFamily(); + + /// See also [messages]. + MessagesProvider call( + String sessionId, + ) { + return MessagesProvider( + sessionId, + ); + } + + @override + MessagesProvider getProviderOverride( + covariant MessagesProvider provider, + ) { + return call( + provider.sessionId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'messagesProvider'; +} + +/// See also [messages]. +class MessagesProvider extends AutoDisposeStreamProvider> { + /// See also [messages]. + MessagesProvider( + String sessionId, + ) : this._internal( + (ref) => messages( + ref as MessagesRef, + sessionId, + ), + from: messagesProvider, + name: r'messagesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$messagesHash, + dependencies: MessagesFamily._dependencies, + allTransitiveDependencies: MessagesFamily._allTransitiveDependencies, + sessionId: sessionId, + ); + + MessagesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.sessionId, + }) : super.internal(); + + final String sessionId; + + @override + Override overrideWith( + Stream> Function(MessagesRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: MessagesProvider._internal( + (ref) => create(ref as MessagesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + sessionId: sessionId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _MessagesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MessagesProvider && other.sessionId == sessionId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, sessionId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MessagesRef on AutoDisposeStreamProviderRef> { + /// The parameter `sessionId` of this provider. + String get sessionId; +} + +class _MessagesProviderElement + extends AutoDisposeStreamProviderElement> with MessagesRef { + _MessagesProviderElement(super.provider); + + @override + String get sessionId => (origin as MessagesProvider).sessionId; +} + +String _$chatNotifierHash() => r'eed2e0994577ffb39e68f70ea55e381e3e24fd2a'; + +/// See also [ChatNotifier]. +@ProviderFor(ChatNotifier) +final chatNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + ChatNotifier.new, + name: r'chatNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$chatNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/apps/mobile/lib/features/chat/domain/providers/session_provider.dart b/apps/mobile/lib/features/chat/domain/providers/session_provider.dart new file mode 100644 index 0000000..d0df4f2 --- /dev/null +++ b/apps/mobile/lib/features/chat/domain/providers/session_provider.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/providers/database_provider.dart'; +import '../../../../core/providers/websocket_provider.dart'; +import '../../../../core/storage/database.dart' as db_lib; + +part 'session_provider.g.dart'; + +// --------------------------------------------------------------------------- +// Current session ID +// --------------------------------------------------------------------------- + +final currentSessionProvider = StateProvider((ref) => null); + +// --------------------------------------------------------------------------- +// Active sessions list +// --------------------------------------------------------------------------- + +@riverpod +class ActiveSessions extends _$ActiveSessions { + StreamSubscription? _messageSubscription; + + @override + Future> build() async { + final db = ref.watch(databaseProvider); + final service = ref.watch(webSocketServiceProvider); + + final cachedAckPayload = service.lastConnectionAckPayload; + if (cachedAckPayload != null) { + await _syncRemoteSessions(cachedAckPayload); + } + + _messageSubscription = service.messages.listen((message) { + if (message.type == BridgeMessageType.connectionAck) { + unawaited(_syncRemoteSessions(message.payload).then((_) { + ref.invalidateSelf(); + })); + } + if (message.type == BridgeMessageType.sessionReady || + message.type == BridgeMessageType.sessionEnd) { + ref.invalidateSelf(); + } + }); + ref.onDispose(() => _messageSubscription?.cancel()); + + final rows = await db.sessionDao.watchAllSessions().first; + return rows.map(_rowToChatSession).toList(); + } + + Future refresh() async { + ref.invalidateSelf(); + } + + Future deleteSession(String sessionId) async { + final db = ref.read(databaseProvider); + await db.sessionDao.deleteSession(sessionId); + ref.invalidateSelf(); + } + + Future _syncRemoteSessions(Map payload) async { + final db = ref.read(databaseProvider); + final rawSessions = payload['active_sessions'] as List? ?? []; + for (final rawSession in rawSessions.whereType>()) { + final sessionId = rawSession['session_id'] as String?; + if (sessionId == null || sessionId.isEmpty) { + continue; + } + + final existing = await db.sessionDao.getSession(sessionId); + final now = DateTime.now().toUtc(); + await db.sessionDao.upsertSession( + db_lib.SessionsCompanion( + id: Value(sessionId), + agentType: Value(rawSession['agent'] as String? ?? 'claude-code'), + agentId: Value(existing?.agentId), + title: Value(_title(rawSession['title'] as String?)), + workingDirectory: + Value(rawSession['working_directory'] as String? ?? ''), + branch: Value(existing?.branch), + status: Value( + (rawSession['status'] as String?) == 'closed' + ? SessionStatus.closed.name + : SessionStatus.active.name, + ), + createdAt: Value(existing?.createdAt ?? now), + lastMessageAt: Value(existing?.lastMessageAt), + updatedAt: Value(now), + synced: const Value(true), + ), + ); + } + } +} + +// --------------------------------------------------------------------------- +// Single session selector +// --------------------------------------------------------------------------- + +final activeSessionProvider = + Provider.family((ref, sessionId) { + final sessions = ref.watch(activeSessionsProvider).valueOrNull ?? []; + try { + return sessions.firstWhere((s) => s.id == sessionId); + } catch (_) { + return null; + } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ChatSession _rowToChatSession(db_lib.Session row) { + return ChatSession( + id: row.id, + agentType: row.agentType, + agentId: row.agentId, + title: row.title, + workingDirectory: row.workingDirectory, + branch: row.branch, + status: SessionStatus.values.firstWhere( + (e) => e.name == row.status, + orElse: () => SessionStatus.active, + ), + createdAt: row.createdAt, + lastMessageAt: row.lastMessageAt, + updatedAt: row.updatedAt, + synced: row.synced, + ); +} + +String _title(String? value) { + if (value != null && value.isNotEmpty) { + return value; + } + return 'Claude Code'; +} diff --git a/apps/mobile/lib/features/chat/domain/providers/session_provider.g.dart b/apps/mobile/lib/features/chat/domain/providers/session_provider.g.dart new file mode 100644 index 0000000..1f94b04 --- /dev/null +++ b/apps/mobile/lib/features/chat/domain/providers/session_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$activeSessionsHash() => r'83c85f93dece06f6d4a19b8d7a9cbf1ca8d27bdc'; + +/// See also [ActiveSessions]. +@ProviderFor(ActiveSessions) +final activeSessionsProvider = AutoDisposeAsyncNotifierProvider>.internal( + ActiveSessions.new, + name: r'activeSessionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$activeSessionsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ActiveSessions = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/apps/mobile/lib/features/chat/presentation/screens/chat_screen.dart b/apps/mobile/lib/features/chat/presentation/screens/chat_screen.dart new file mode 100644 index 0000000..948da1a --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/screens/chat_screen.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/message_models.dart'; +import '../../domain/providers/chat_provider.dart'; +import '../../domain/providers/session_provider.dart'; +import '../widgets/chat_input_bar.dart'; +import '../widgets/message_bubble.dart'; +import '../widgets/voice_input_sheet.dart'; +import 'session_list_screen.dart'; + +class ChatScreen extends ConsumerStatefulWidget { + final String sessionId; + const ChatScreen({super.key, required this.sessionId}); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + void _sendMessage(String text) { + ref.read(chatNotifierProvider.notifier).sendMessage(widget.sessionId, text); + _scrollToBottom(); + } + + void _openVoice() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => VoiceInputSheet( + onSend: (text) { + Navigator.pop(context); + _sendMessage(text); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + ref.watch(chatNotifierProvider); + final session = ref.watch(activeSessionProvider(widget.sessionId)); + final messagesAsync = ref.watch(messagesProvider(widget.sessionId)); + final streamingMap = ref.watch(streamingMessageProvider); + final streamingText = streamingMap[widget.sessionId]; + + // Auto-scroll when messages change + ref.listen( + messagesProvider(widget.sessionId), (_, __) => _scrollToBottom()); + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth >= 720; + + if (isTablet) { + return Row( + children: [ + const SizedBox( + width: 300, + child: SessionListScreen(), + ), + const VerticalDivider(width: 1, color: Color(0xFF3C3C3C)), + Expanded( + child: _ChatBody( + sessionId: widget.sessionId, + sessionTitle: session?.title ?? 'Chat', + branch: session?.branch, + messagesAsync: messagesAsync, + streamingText: streamingText, + scrollController: _scrollController, + onSend: _sendMessage, + onVoice: _openVoice, + )), + ], + ); + } + + return _ChatBody( + sessionId: widget.sessionId, + sessionTitle: session?.title ?? 'Chat', + branch: session?.branch, + messagesAsync: messagesAsync, + streamingText: streamingText, + scrollController: _scrollController, + onSend: _sendMessage, + onVoice: _openVoice, + ); + }, + ); + } +} + +class _ChatBody extends StatelessWidget { + final String sessionId; + final String sessionTitle; + final String? branch; + final AsyncValue> messagesAsync; + final String? streamingText; + final ScrollController scrollController; + final void Function(String) onSend; + final VoidCallback onVoice; + + const _ChatBody({ + required this.sessionId, + required this.sessionTitle, + required this.branch, + required this.messagesAsync, + required this.streamingText, + required this.scrollController, + required this.onSend, + required this.onVoice, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1E1E1E), + appBar: AppBar( + backgroundColor: const Color(0xFF252526), + title: Row( + children: [ + Expanded( + child: Text( + sessionTitle, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white), + ), + ), + if (branch != null) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF3C3C3C), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.call_split, + size: 12, color: Color(0xFF4EC9B0)), + const SizedBox(width: 4), + Text( + branch!, + style: const TextStyle( + color: Color(0xFF4EC9B0), fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + body: Column( + children: [ + Expanded( + child: messagesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('Error: $e', + style: const TextStyle(color: Colors.redAccent)), + ), + data: (messages) { + final hasStreaming = + streamingText != null && streamingText!.isNotEmpty; + final itemCount = messages.length + (hasStreaming ? 1 : 0); + + if (itemCount == 0) { + return const Center( + child: Text( + 'Start the conversation', + style: TextStyle(color: Colors.grey), + ), + ); + } + + return ListView.builder( + controller: scrollController, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: itemCount, + itemBuilder: (context, i) { + if (i < messages.length) { + return MessageBubble(message: messages[i]); + } + // Streaming placeholder + return MessageBubble.streaming( + sessionId: sessionId, text: streamingText!); + }, + ); + }, + ), + ), + ChatInputBar( + sessionId: sessionId, + onSend: onSend, + onVoice: onVoice, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/screens/session_list_screen.dart b/apps/mobile/lib/features/chat/presentation/screens/session_list_screen.dart new file mode 100644 index 0000000..950ea31 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/screens/session_list_screen.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../../../core/network/connection_state.dart'; +import '../../../../core/providers/bridge_provider.dart'; +import '../../domain/providers/chat_provider.dart'; +import '../../domain/providers/session_provider.dart'; + +class SessionListScreen extends ConsumerWidget { + const SessionListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(chatNotifierProvider); + final sessionsAsync = ref.watch(activeSessionsProvider); + + return Scaffold( + backgroundColor: const Color(0xFF1E1E1E), + appBar: AppBar( + backgroundColor: const Color(0xFF252526), + title: const Text('Sessions'), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showNewSessionSheet(context, ref), + backgroundColor: const Color(0xFF569CD6), + icon: const Icon(Icons.add), + label: const Text('New Session'), + ), + body: sessionsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text( + 'Error: $e', + style: const TextStyle(color: Colors.redAccent), + ), + ), + data: (sessions) { + if (sessions.isEmpty) { + return const Center( + child: Text( + 'No sessions yet.\nTap + to start a Claude Code Agent SDK session.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ); + } + return RefreshIndicator( + onRefresh: () => + ref.read(activeSessionsProvider.notifier).refresh(), + child: ListView.separated( + padding: const EdgeInsets.all(8), + itemCount: sessions.length, + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, i) => _SessionTile(session: sessions[i]), + ), + ); + }, + ), + ); + } + + Future _showNewSessionSheet(BuildContext context, WidgetRef ref) async { + final sessionId = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF252526), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => const _NewSessionSheet(), + ); + + if (sessionId != null && context.mounted) { + ref.read(currentSessionProvider.notifier).state = sessionId; + context.go('/home/chat/$sessionId'); + } + } +} + +class _SessionTile extends ConsumerWidget { + final ChatSession session; + const _SessionTile({required this.session}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final timeLabel = session.lastMessageAt != null + ? DateFormat.jm().format(session.lastMessageAt!) + : ''; + final statusColor = switch (session.status) { + SessionStatus.active => const Color(0xFF4EC9B0), + SessionStatus.paused => Colors.orange, + SessionStatus.closed => Colors.grey, + }; + + return GestureDetector( + onLongPress: () => _confirmDelete(context, ref), + child: ListTile( + tileColor: const Color(0xFF252526), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + leading: CircleAvatar( + backgroundColor: const Color(0xFF3C3C3C), + child: Text( + session.agentType.substring(0, 1).toUpperCase(), + style: const TextStyle(color: Color(0xFF569CD6)), + ), + ), + title: Text( + session.title.isNotEmpty + ? session.title + : 'Session ${session.id.substring(0, 8)}', + style: const TextStyle(color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + session.workingDirectory.isEmpty + ? 'Waiting for bridge details' + : session.workingDirectory, + style: const TextStyle(color: Colors.grey, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + timeLabel, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + ], + ), + onTap: () { + ref.read(currentSessionProvider.notifier).state = session.id; + context.go('/home/chat/${session.id}'); + }, + ), + ); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: const Color(0xFF252526), + title: const Text( + 'Delete Session', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'Remove this session and all its messages?', + style: TextStyle(color: Colors.grey), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + 'Delete', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(activeSessionsProvider.notifier).deleteSession(session.id); + } + } +} + +class _NewSessionSheet extends ConsumerStatefulWidget { + const _NewSessionSheet(); + + @override + ConsumerState<_NewSessionSheet> createState() => _NewSessionSheetState(); +} + +class _NewSessionSheetState extends ConsumerState<_NewSessionSheet> { + final TextEditingController _workingDirectoryController = + TextEditingController(); + bool _submitting = false; + String? _error; + + @override + void dispose() { + _workingDirectoryController.dispose(); + super.dispose(); + } + + Future _startSession() async { + final workingDirectory = _workingDirectoryController.text.trim(); + if (workingDirectory.isEmpty) { + setState(() { + _error = 'Working directory is required.'; + }); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + + try { + final sessionId = await ref + .read(chatNotifierProvider.notifier) + .startSession('claude-code', workingDirectory); + if (mounted) { + Navigator.of(context).pop(sessionId); + } + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _submitting = false; + _error = error.toString(); + }); + } + } + + @override + Widget build(BuildContext context) { + final bridgeStatus = ref.watch(bridgeProvider); + final isConnected = bridgeStatus == ConnectionStatus.connected; + + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Start Claude Code Session', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + isConnected + ? 'This starts a parallel Agent SDK session on the connected bridge.' + : 'Bridge is offline. The session start request will queue locally and send after reconnect.', + style: TextStyle( + color: isConnected ? const Color(0xFF9CDCFE) : Colors.orange, + ), + ), + const SizedBox(height: 16), + TextField( + key: const Key('newSessionWorkingDirectoryField'), + controller: _workingDirectoryController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Working Directory', + labelStyle: const TextStyle(color: Color(0xFF9CDCFE)), + hintText: '/Users/me/project', + hintStyle: const TextStyle(color: Colors.grey), + filled: true, + fillColor: const Color(0xFF1E1E1E), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submitting ? null : _startSession(), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: const TextStyle(color: Colors.redAccent), + ), + ], + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _submitting ? null : _startSession, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(isConnected ? Icons.play_arrow : Icons.schedule_send), + label: Text(isConnected ? 'Start Session' : 'Queue Session Start'), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/chat_input_bar.dart b/apps/mobile/lib/features/chat/presentation/widgets/chat_input_bar.dart new file mode 100644 index 0000000..d8c95e9 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/chat_input_bar.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/network/bridge_socket.dart'; + +class ChatInputBar extends ConsumerStatefulWidget { + final String sessionId; + final void Function(String) onSend; + final VoidCallback onVoice; + + const ChatInputBar({ + super.key, + required this.sessionId, + required this.onSend, + required this.onVoice, + }); + + @override + ConsumerState createState() => _ChatInputBarState(); +} + +class _ChatInputBarState extends ConsumerState { + final _controller = TextEditingController(); + bool _hasText = false; + + @override + void initState() { + super.initState(); + _controller.addListener(() { + final newHasText = _controller.text.trim().isNotEmpty; + if (newHasText != _hasText) { + setState(() => _hasText = newHasText); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _send() { + final text = _controller.text.trim(); + if (text.isEmpty) { + return; + } + _controller.clear(); + widget.onSend(text); + } + + @override + Widget build(BuildContext context) { + final socketState = ref.watch(bridgeSocketStateProvider).valueOrNull; + final isConnected = socketState == ConnectionStatus.connected; + final isReconnecting = socketState == ConnectionStatus.reconnecting; + + return Container( + color: const Color(0xFF252526), + padding: const EdgeInsets.fromLTRB(8, 6, 8, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isConnected) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + color: Colors.orange.withValues(alpha: 0.15), + child: Row( + children: [ + Icon( + isReconnecting ? Icons.wifi_find : Icons.wifi_off, + size: 12, + color: Colors.orange, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + isReconnecting + ? 'Reconnecting — messages will queue locally' + : 'Offline — messages will send when reconnected', + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + ), + ), + ), + ], + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Semantics( + label: 'Voice input', + button: true, + child: IconButton( + icon: const Icon(Icons.mic, color: Color(0xFF9CDCFE)), + onPressed: widget.onVoice, + tooltip: 'Voice input', + ), + ), + Expanded( + child: Semantics( + label: 'Message input', + textField: true, + child: TextField( + controller: _controller, + maxLines: 8, + minLines: 1, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Message…', + hintStyle: const TextStyle(color: Colors.grey), + filled: true, + fillColor: const Color(0xFF3C3C3C), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + counterText: _controller.text.length > 200 + ? '${_controller.text.length} chars' + : null, + counterStyle: const TextStyle( + color: Colors.grey, + fontSize: 11, + ), + ), + textInputAction: TextInputAction.newline, + ), + ), + ), + const SizedBox(width: 4), + AnimatedOpacity( + opacity: _hasText ? 1.0 : 0.4, + duration: const Duration(milliseconds: 150), + child: Semantics( + label: isConnected ? 'Send message' : 'Queue message', + button: true, + child: IconButton( + icon: Icon( + isConnected ? Icons.send : Icons.schedule_send, + color: + isConnected ? const Color(0xFF569CD6) : Colors.orange, + ), + onPressed: _hasText ? _send : null, + tooltip: isConnected ? 'Send' : 'Queue message', + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/message_bubble.dart b/apps/mobile/lib/features/chat/presentation/widgets/message_bubble.dart new file mode 100644 index 0000000..e5a7b15 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/message_bubble.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/models/message_models.dart'; +import 'message_part_widget.dart'; +import 'streaming_text.dart'; + +class MessageBubble extends StatelessWidget { + final Message? message; + final bool isStreaming; + final String? streamingSessionId; + final String? streamingText; + + const MessageBubble({ + super.key, + required Message this.message, + }) : isStreaming = false, + streamingSessionId = null, + streamingText = null; + + const MessageBubble.streaming({ + super.key, + required String sessionId, + required String text, + }) : message = null, + isStreaming = true, + streamingSessionId = sessionId, + streamingText = text; + + @override + Widget build(BuildContext context) { + if (isStreaming) { + return _BubbleLayout( + isUser: false, + timestamp: null, + child: StreamingText(text: streamingText ?? '', isStreaming: true), + ); + } + + final msg = message!; + final isUser = msg.role == MessageRole.user; + + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 250), + builder: (context, value, child) => Opacity(opacity: value, child: child), + child: GestureDetector( + onLongPress: () => _showTimestamp(context, msg.createdAt), + child: _BubbleLayout( + isUser: isUser, + timestamp: null, + child: isUser + ? Text( + msg.content, + style: const TextStyle(color: Colors.white), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: msg.parts + .map((p) => MessagePartWidget( + part: p, + metadata: msg.metadata, + )) + .toList(), + ), + ), + ), + ); + } + + void _showTimestamp(BuildContext context, DateTime ts) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(DateFormat('MMM d, y HH:mm:ss').format(ts)), + duration: const Duration(seconds: 2), + backgroundColor: const Color(0xFF3C3C3C), + ), + ); + } +} + +class _BubbleLayout extends StatelessWidget { + final bool isUser; + final DateTime? timestamp; + final Widget child; + + const _BubbleLayout({ + required this.isUser, + required this.timestamp, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + constraints: + BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78), + decoration: BoxDecoration( + color: isUser ? const Color(0xFF569CD6) : const Color(0xFF252526), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(12), + topRight: const Radius.circular(12), + bottomLeft: Radius.circular(isUser ? 12 : 4), + bottomRight: Radius.circular(isUser ? 4 : 12), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: child, + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/message_part_widget.dart b/apps/mobile/lib/features/chat/presentation/widgets/message_part_widget.dart new file mode 100644 index 0000000..672a360 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/message_part_widget.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import '../../../../core/models/message_models.dart'; +import 'tool_card.dart'; + +class MessagePartWidget extends StatelessWidget { + final MessagePart part; + + /// Optional metadata from the parent message (used for approval state). + final Map? metadata; + + const MessagePartWidget({ + super.key, + required this.part, + this.metadata, + }); + + @override + Widget build(BuildContext context) { + return part.when( + text: (content) => MarkdownBody( + data: content, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(color: Colors.white, height: 1.4), + code: const TextStyle( + fontFamily: 'JetBrainsMono', + color: Color(0xFF9CDCFE), + backgroundColor: Color(0xFF1E1E1E), + ), + codeblockDecoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(6), + ), + h1: const TextStyle( + color: Color(0xFF569CD6), fontWeight: FontWeight.bold), + h2: const TextStyle( + color: Color(0xFF569CD6), fontWeight: FontWeight.bold), + h3: const TextStyle(color: Color(0xFF569CD6)), + blockquote: const TextStyle(color: Color(0xFF9CDCFE)), + listBullet: const TextStyle(color: Colors.grey), + ), + ), + toolUse: (tool, params, id) => ToolCard( + toolName: tool, + params: params, + id: id, + isCompleted: false, + metadata: metadata, + ), + toolResult: (toolCallId, result) => ToolCard( + toolName: (result.metadata?['tool'] as String?) ?? toolCallId, + params: const {}, + id: toolCallId, + isCompleted: true, + result: result, + metadata: metadata, + ), + thinking: (content) => _ThinkingBlock(content: content), + ); + } +} + +class _ThinkingBlock extends StatefulWidget { + final String content; + const _ThinkingBlock({required this.content}); + + @override + State<_ThinkingBlock> createState() => _ThinkingBlockState(); +} + +class _ThinkingBlockState extends State<_ThinkingBlock> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + border: Border.all(color: const Color(0xFF3C3C3C)), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.psychology, size: 14, color: Colors.grey), + const SizedBox(width: 4), + const Text( + 'Thinking...', + style: TextStyle( + color: Colors.grey, + fontStyle: FontStyle.italic, + fontSize: 12, + ), + ), + const Spacer(), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 14, + color: Colors.grey, + ), + ], + ), + if (_expanded) ...[ + const SizedBox(height: 8), + Text( + widget.content, + style: const TextStyle( + color: Colors.grey, + fontStyle: FontStyle.italic, + fontSize: 12, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/streaming_text.dart b/apps/mobile/lib/features/chat/presentation/widgets/streaming_text.dart new file mode 100644 index 0000000..073fc63 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/streaming_text.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class StreamingText extends StatefulWidget { + final String text; + final bool isStreaming; + + const StreamingText({ + super.key, + required this.text, + this.isStreaming = true, + }); + + @override + State createState() => _StreamingTextState(); +} + +class _StreamingTextState extends State + with SingleTickerProviderStateMixin { + late AnimationController _cursorController; + late Animation _cursorOpacity; + Timer? _cursorTimer; + + @override + void initState() { + super.initState(); + _cursorController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 530), + ); + _cursorOpacity = + Tween(begin: 0, end: 1).animate(_cursorController); + if (widget.isStreaming) { + _cursorController.repeat(reverse: true); + } + } + + @override + void didUpdateWidget(StreamingText old) { + super.didUpdateWidget(old); + if (!old.isStreaming && widget.isStreaming) { + _cursorController.repeat(reverse: true); + } else if (old.isStreaming && !widget.isStreaming) { + _cursorController.stop(); + _cursorController.value = 0; + } + } + + @override + void dispose() { + _cursorTimer?.cancel(); + _cursorController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: widget.text, + style: const TextStyle(color: Colors.white, height: 1.4), + ), + if (widget.isStreaming) + WidgetSpan( + child: FadeTransition( + opacity: _cursorOpacity, + child: const Text( + '|', + style: TextStyle( + color: Color(0xFF569CD6), + fontWeight: FontWeight.w100, + fontSize: 16), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/tool_card.dart b/apps/mobile/lib/features/chat/presentation/widgets/tool_card.dart new file mode 100644 index 0000000..697009d --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/tool_card.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/message_models.dart'; + +/// Visual state of a tool card in the chat timeline. +enum ToolState { + /// Tool is pending user approval. + pendingApproval, + + /// Tool is executing (running). + running, + + /// Tool completed successfully. + completed, + + /// Tool failed. + failed, +} + +class ToolCard extends StatefulWidget { + final String toolName; + final Map params; + final String? id; + final bool isCompleted; + final ToolResult? result; + + /// Optional metadata from parent message (contains approval state like 'risk_level'). + final Map? metadata; + + const ToolCard({ + super.key, + required this.toolName, + required this.params, + this.id, + required this.isCompleted, + this.result, + this.metadata, + }); + + @override + State createState() => _ToolCardState(); +} + +class _ToolCardState extends State + with SingleTickerProviderStateMixin { + bool _paramsExpanded = false; + bool _resultExpanded = false; + late AnimationController _expandController; + late Animation _expandAnimation; + + /// Determines the visual state of this tool card. + ToolState get _state { + final riskLevel = widget.metadata?['risk_level'] as String?; + // If risk_level exists in metadata and tool is not completed, it's pending approval + if (riskLevel != null && !widget.isCompleted) { + return ToolState.pendingApproval; + } + if (!widget.isCompleted) return ToolState.running; + if (widget.result?.success ?? false) return ToolState.completed; + return ToolState.failed; + } + + /// The risk level from metadata (defaults to low if not set). + RiskLevel get _riskLevel { + final level = widget.metadata?['risk_level'] as String?; + return switch (level) { + 'medium' => RiskLevel.medium, + 'high' => RiskLevel.high, + 'critical' => RiskLevel.critical, + _ => RiskLevel.low, + }; + } + + @override + void initState() { + super.initState(); + _expandController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _expandAnimation = CurvedAnimation( + parent: _expandController, + curve: Curves.easeInOut, + ); + if (widget.isCompleted) _expandController.forward(); + } + + @override + void didUpdateWidget(ToolCard old) { + super.didUpdateWidget(old); + if (!old.isCompleted && widget.isCompleted) { + _expandController.forward(); + setState(() => _resultExpanded = true); + } + } + + @override + void dispose() { + _expandController.dispose(); + super.dispose(); + } + + Color get _statusColor { + return switch (_state) { + ToolState.pendingApproval => _riskBadgeColor, + ToolState.running => const Color(0xFF569CD6), + ToolState.completed => const Color(0xFF4EC9B0), + ToolState.failed => Colors.redAccent, + }; + } + + Color get _riskBadgeColor { + return switch (_riskLevel) { + RiskLevel.low => const Color(0xFF4CAF50), + RiskLevel.medium => const Color(0xFFFF9800), + RiskLevel.high => const Color(0xFFF44747), + RiskLevel.critical => const Color(0xFF8B0000), + }; + } + + IconData get _statusIcon { + return switch (_state) { + ToolState.pendingApproval => Icons.pending_actions, + ToolState.running => Icons.hourglass_empty, + ToolState.completed => Icons.check_circle, + ToolState.failed => Icons.error, + }; + } + + String get _statusLabel { + return switch (_state) { + ToolState.pendingApproval => 'approval required', + ToolState.running => 'running', + ToolState.completed => 'succeeded', + ToolState.failed => 'failed', + }; + } + + @override + Widget build(BuildContext context) { + final isPendingApproval = _state == ToolState.pendingApproval; + + return Semantics( + label: 'Tool: ${widget.toolName}, status: $_statusLabel', + child: Card( + color: const Color(0xFF2D2D2D), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isPendingApproval + ? BorderSide( + color: _riskBadgeColor.withValues(alpha: 0.5), width: 2) + : BorderSide.none, + ), + margin: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with risk badge for pending approvals + Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + _ToolIcon(toolName: widget.toolName), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.toolName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontFamily: 'JetBrainsMono', + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isPendingApproval) ...[ + _RiskBadge(level: _riskLevel, color: _riskBadgeColor), + const SizedBox(width: 8), + ], + Icon(_statusIcon, color: _statusColor, size: 18), + ], + ), + ), + // Approval required banner + if (isPendingApproval) _ApprovalBanner(riskLevel: _riskLevel), + if (widget.params.isNotEmpty) + _ExpandableSection( + label: 'Parameters', + expanded: _paramsExpanded, + onTap: () => setState(() => _paramsExpanded = !_paramsExpanded), + child: _KeyValueList(map: widget.params), + ), + if (widget.isCompleted && widget.result != null) + SizeTransition( + sizeFactor: _expandAnimation, + child: _ExpandableSection( + label: 'Result', + expanded: _resultExpanded, + onTap: () => + setState(() => _resultExpanded = !_resultExpanded), + child: _ResultContent(result: widget.result!), + ), + ), + ], + ), + ), + ); + } +} + +/// Risk level badge shown in pending approval state. +class _RiskBadge extends StatelessWidget { + final RiskLevel level; + final Color color; + + const _RiskBadge({required this.level, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + level.name.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } +} + +/// Banner shown when approval is required for a tool. +class _ApprovalBanner extends StatelessWidget { + final RiskLevel riskLevel; + + const _ApprovalBanner({required this.riskLevel}); + + @override + Widget build(BuildContext context) { + final color = switch (riskLevel) { + RiskLevel.low => const Color(0xFF4CAF50), + RiskLevel.medium => const Color(0xFFFF9800), + RiskLevel.high => const Color(0xFFF44747), + RiskLevel.critical => const Color(0xFF8B0000), + }; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(Icons.pending_actions, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Approval required — check Approvals tab', + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} + +class _ToolIcon extends StatelessWidget { + final String toolName; + const _ToolIcon({required this.toolName}); + + IconData get _icon { + final name = toolName.toLowerCase(); + if (name.contains('file') || + name.contains('read') || + name.contains('write')) { + return Icons.description; + } + if (name.contains('bash') || + name.contains('shell') || + name.contains('exec')) { + return Icons.terminal; + } + if (name.contains('search') || name.contains('grep')) { + return Icons.search; + } + if (name.contains('git')) return Icons.call_split; + if (name.contains('web') || name.contains('http')) return Icons.language; + return Icons.build; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFF3C3C3C), + borderRadius: BorderRadius.circular(4), + ), + child: Icon(_icon, size: 14, color: const Color(0xFF569CD6)), + ); + } +} + +class _ExpandableSection extends StatelessWidget { + final String label; + final bool expanded; + final VoidCallback onTap; + final Widget child; + + const _ExpandableSection({ + required this.label, + required this.expanded, + required this.onTap, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(color: Color(0xFF3C3C3C), height: 1), + Semantics( + label: 'Expand tool parameters', + button: true, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + Text(label, + style: const TextStyle( + color: Colors.grey, + fontSize: 11, + fontFamily: 'JetBrainsMono')), + const Spacer(), + Icon( + expanded ? Icons.expand_less : Icons.expand_more, + size: 14, + color: Colors.grey, + ), + ], + ), + ), + ), + ), + if (expanded) + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + child: child, + ), + ], + ); + } +} + +class _KeyValueList extends StatelessWidget { + final Map map; + const _KeyValueList({required this.map}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: map.entries.map((e) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${e.key}: ', + style: const TextStyle( + color: Color(0xFF9CDCFE), + fontFamily: 'JetBrainsMono', + fontSize: 12)), + TextSpan( + text: e.value.toString(), + style: const TextStyle( + color: Color(0xFFCE9178), + fontFamily: 'JetBrainsMono', + fontSize: 12)), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class _ResultContent extends StatelessWidget { + final ToolResult result; + const _ResultContent({required this.result}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.error != null) + Text( + result.error!, + style: const TextStyle( + color: Colors.redAccent, + fontFamily: 'JetBrainsMono', + fontSize: 12), + ) + else + Text( + result.content, + style: const TextStyle( + color: Color(0xFF4EC9B0), + fontFamily: 'JetBrainsMono', + fontSize: 12), + maxLines: 10, + overflow: TextOverflow.ellipsis, + ), + if (result.durationMs != null) ...[ + const SizedBox(height: 4), + Text( + '${result.durationMs}ms', + style: const TextStyle(color: Colors.grey, fontSize: 11), + ), + ], + ], + ); + } +} diff --git a/apps/mobile/lib/features/chat/presentation/widgets/voice_input_sheet.dart b/apps/mobile/lib/features/chat/presentation/widgets/voice_input_sheet.dart new file mode 100644 index 0000000..5f21dc0 --- /dev/null +++ b/apps/mobile/lib/features/chat/presentation/widgets/voice_input_sheet.dart @@ -0,0 +1,195 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:speech_to_text/speech_to_text.dart' as stt; + +class VoiceInputSheet extends StatefulWidget { + final void Function(String text) onSend; + + const VoiceInputSheet({super.key, required this.onSend}); + + @override + State createState() => _VoiceInputSheetState(); +} + +class _VoiceInputSheetState extends State + with SingleTickerProviderStateMixin { + final _speech = stt.SpeechToText(); + bool _listening = false; + String _transcript = ''; + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _pulseAnimation = Tween(begin: 0.9, end: 1.1).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + _startListening(); + } + + Future _startListening() async { + final available = await _speech.initialize( + onStatus: (status) { + if (status == 'done' || status == 'notListening') { + if (mounted) setState(() => _listening = false); + _pulseController.stop(); + } + }, + onError: (_) { + if (mounted) setState(() => _listening = false); + _pulseController.stop(); + }, + ); + if (!available) return; + + await _speech.listen( + onResult: (result) { + if (mounted) { + setState(() => _transcript = result.recognizedWords); + } + }, + listenFor: const Duration(seconds: 30), + pauseFor: const Duration(seconds: 3), + ); + if (mounted) setState(() => _listening = true); + _pulseController.repeat(reverse: true); + } + + Future _stopListening() async { + await _speech.stop(); + _pulseController.stop(); + if (mounted) setState(() => _listening = false); + } + + @override + void dispose() { + _speech.stop(); + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFF252526), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) => Transform.scale( + scale: _listening ? _pulseAnimation.value : 1.0, + child: child, + ), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _listening + ? const Color(0xFF569CD6).withOpacity(0.2) + : const Color(0xFF3C3C3C), + border: Border.all( + color: _listening + ? const Color(0xFF569CD6) + : Colors.grey, + width: 2, + ), + ), + child: Icon( + _listening ? Icons.mic : Icons.mic_off, + color: _listening + ? const Color(0xFF569CD6) + : Colors.grey, + size: 36, + ), + ), + ), + const SizedBox(height: 16), + Text( + _listening ? 'Listening…' : 'Tap mic to start', + style: TextStyle( + color: _listening + ? const Color(0xFF569CD6) + : Colors.grey, + fontSize: 14, + ), + ), + const SizedBox(height: 20), + Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 60), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _transcript.isEmpty ? 'Your words will appear here…' : _transcript, + style: TextStyle( + color: _transcript.isEmpty ? Colors.grey : Colors.white, + fontStyle: _transcript.isEmpty + ? FontStyle.italic + : FontStyle.normal, + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.grey), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Cancel', + style: TextStyle(color: Colors.grey)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: _transcript.isEmpty + ? null + : () { + _stopListening(); + widget.onSend(_transcript); + }, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF569CD6), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Send'), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/diff/domain/providers/diff_provider.dart b/apps/mobile/lib/features/diff/domain/providers/diff_provider.dart new file mode 100644 index 0000000..6e1c313 --- /dev/null +++ b/apps/mobile/lib/features/diff/domain/providers/diff_provider.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../../../core/network/bridge_socket.dart'; + +part 'diff_provider.g.dart'; + +enum DiffViewMode { unified, splitView } + +final currentDiffProvider = StateProvider?>((ref) => null); + +final diffViewModeProvider = + StateProvider((ref) => DiffViewMode.unified); + +@riverpod +class DiffNotifier extends _$DiffNotifier { + @override + Future build() async { + final socket = ref.watch(bridgeSocketProvider); + socket.messageStream.listen((msg) { + if (msg['type'] == 'diff_result') { + final rawFiles = msg['files'] as List? ?? []; + final files = rawFiles + .whereType>() + .map((f) => DiffFile.fromJson(f)) + .toList(); + ref.read(currentDiffProvider.notifier).state = files; + } + }); + } + + Future requestDiff(String sessionId, {List? files}) async { + final socket = ref.read(bridgeSocketProvider); + socket.send({ + 'type': 'request_diff', + 'session_id': sessionId, + if (files != null) 'files': files, + }); + } + + void clearDiff() { + ref.read(currentDiffProvider.notifier).state = null; + } +} diff --git a/apps/mobile/lib/features/diff/domain/providers/diff_provider.g.dart b/apps/mobile/lib/features/diff/domain/providers/diff_provider.g.dart new file mode 100644 index 0000000..c43533f --- /dev/null +++ b/apps/mobile/lib/features/diff/domain/providers/diff_provider.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'diff_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$diffNotifierHash() => r'89a62a84c8ba9e5bd406efb244969816bd3f4ebf'; + +/// See also [DiffNotifier]. +@ProviderFor(DiffNotifier) +final diffNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + DiffNotifier.new, + name: r'diffNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$diffNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$DiffNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/apps/mobile/lib/features/diff/presentation/screens/diff_viewer_screen.dart b/apps/mobile/lib/features/diff/presentation/screens/diff_viewer_screen.dart new file mode 100644 index 0000000..2b844a4 --- /dev/null +++ b/apps/mobile/lib/features/diff/presentation/screens/diff_viewer_screen.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../domain/providers/diff_provider.dart'; +import '../widgets/diff_viewer.dart'; +import '../widgets/diff_hunk_view.dart'; + +class DiffViewerScreen extends ConsumerStatefulWidget { + const DiffViewerScreen({super.key}); + + @override + ConsumerState createState() => _DiffViewerScreenState(); +} + +class _DiffViewerScreenState extends ConsumerState { + int _selectedFileIndex = 0; + + String _statusLabel(FileChangeStatus status) { + return switch (status) { + FileChangeStatus.modified => 'M', + FileChangeStatus.added => 'A', + FileChangeStatus.deleted => 'D', + FileChangeStatus.renamed => 'R', + FileChangeStatus.untracked => '?', + }; + } + + Color _statusColor(FileChangeStatus status) { + return switch (status) { + FileChangeStatus.modified => Colors.orange, + FileChangeStatus.added => const Color(0xFF4EC9B0), + FileChangeStatus.deleted => Colors.redAccent, + FileChangeStatus.renamed => const Color(0xFF569CD6), + FileChangeStatus.untracked => Colors.grey, + }; + } + + Widget _buildFileSidebar(List files, DiffViewMode viewMode) { + return ListView.builder( + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + final isSelected = index == _selectedFileIndex; + final color = _statusColor(file.status); + return InkWell( + onTap: () => setState(() => _selectedFileIndex = index), + child: Container( + color: isSelected + ? const Color(0xFF2A2D2E) + : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: color.withOpacity(0.2), + borderRadius: BorderRadius.circular(3), + ), + alignment: Alignment.center, + child: Text( + _statusLabel(file.status), + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + fontFamily: 'JetBrainsMono', + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.path.split('/').last, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontFamily: 'JetBrainsMono', + ), + overflow: TextOverflow.ellipsis, + ), + Row( + children: [ + Text( + '+${file.additions}', + style: const TextStyle( + color: Color(0xFF4EC9B0), + fontSize: 10, + fontFamily: 'JetBrainsMono', + ), + ), + const SizedBox(width: 4), + Text( + '-${file.deletions}', + style: const TextStyle( + color: Colors.redAccent, + fontSize: 10, + fontFamily: 'JetBrainsMono', + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildDiffDetail(DiffFile file, DiffViewMode viewMode) { + if (file.hunks.isEmpty) { + return const Center( + child: Text( + 'No hunks to display', + style: TextStyle(color: Colors.grey), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: file.hunks.length, + itemBuilder: (context, i) => + DiffHunkView(hunk: file.hunks[i], viewMode: viewMode), + ); + } + + @override + Widget build(BuildContext context) { + final diff = ref.watch(currentDiffProvider); + final viewMode = ref.watch(diffViewModeProvider); + + final fileCount = diff?.length ?? 0; + final additions = diff?.fold(0, (sum, f) => sum + f.additions) ?? 0; + final deletions = diff?.fold(0, (sum, f) => sum + f.deletions) ?? 0; + + return Scaffold( + backgroundColor: const Color(0xFF1E1E1E), + appBar: AppBar( + backgroundColor: const Color(0xFF252526), + title: Row( + children: [ + Text( + '$fileCount ${fileCount == 1 ? 'file' : 'files'}', + style: const TextStyle(color: Colors.white), + ), + const SizedBox(width: 12), + Text( + '+$additions', + style: const TextStyle(color: Color(0xFF4EC9B0), fontSize: 13), + ), + const SizedBox(width: 6), + Text( + '-$deletions', + style: const TextStyle(color: Colors.redAccent, fontSize: 13), + ), + ], + ), + actions: [ + IconButton( + tooltip: viewMode == DiffViewMode.unified + ? 'Switch to split view' + : 'Switch to unified view', + icon: Icon( + viewMode == DiffViewMode.unified + ? Icons.vertical_split + : Icons.view_stream, + color: const Color(0xFF9CDCFE), + ), + onPressed: () { + ref.read(diffViewModeProvider.notifier).state = + viewMode == DiffViewMode.unified + ? DiffViewMode.splitView + : DiffViewMode.unified; + }, + ), + ], + ), + body: diff == null || diff.isEmpty + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.difference_outlined, size: 48, color: Colors.grey), + SizedBox(height: 12), + Text( + 'No diff to display', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + final isTabletLandscape = constraints.maxWidth >= 600; + + if (isTabletLandscape) { + // Clamp selected index in case diff changed + final safeIndex = + _selectedFileIndex.clamp(0, diff.length - 1); + + return Row( + children: [ + // Left 35%: file list sidebar + SizedBox( + width: constraints.maxWidth * 0.35, + child: Container( + decoration: const BoxDecoration( + color: Color(0xFF252526), + border: Border( + right: BorderSide(color: Color(0xFF3C3C3C)), + ), + ), + child: _buildFileSidebar(diff, viewMode), + ), + ), + // Right 65%: selected file diff hunks + Expanded( + child: _buildDiffDetail(diff[safeIndex], viewMode), + ), + ], + ); + } + + // Portrait: original single-panel ListView + return DiffViewer(files: diff, viewMode: viewMode); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/features/diff/presentation/widgets/diff_file_card.dart b/apps/mobile/lib/features/diff/presentation/widgets/diff_file_card.dart new file mode 100644 index 0000000..ca44eed --- /dev/null +++ b/apps/mobile/lib/features/diff/presentation/widgets/diff_file_card.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../domain/providers/diff_provider.dart'; +import 'diff_hunk_view.dart'; + +class DiffFileCard extends StatelessWidget { + final DiffFile file; + final DiffViewMode viewMode; + + const DiffFileCard({ + super.key, + required this.file, + required this.viewMode, + }); + + String get _statusLabel { + return switch (file.status) { + FileChangeStatus.modified => 'M', + FileChangeStatus.added => 'A', + FileChangeStatus.deleted => 'D', + FileChangeStatus.renamed => 'R', + FileChangeStatus.untracked => '?', + }; + } + + Color get _statusColor { + return switch (file.status) { + FileChangeStatus.modified => Colors.orange, + FileChangeStatus.added => const Color(0xFF4EC9B0), + FileChangeStatus.deleted => Colors.redAccent, + FileChangeStatus.renamed => const Color(0xFF569CD6), + FileChangeStatus.untracked => Colors.grey, + }; + } + + @override + Widget build(BuildContext context) { + return Card( + color: const Color(0xFF252526), + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + childrenPadding: EdgeInsets.zero, + collapsedBackgroundColor: Colors.transparent, + backgroundColor: Colors.transparent, + shape: const Border(), + collapsedShape: const Border(), + leading: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: _statusColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text( + _statusLabel, + style: TextStyle( + color: _statusColor, + fontSize: 11, + fontWeight: FontWeight.bold, + fontFamily: 'JetBrainsMono', + ), + ), + ), + title: Text( + file.path, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontFamily: 'JetBrainsMono', + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '+${file.additions}', + style: const TextStyle( + color: Color(0xFF4EC9B0), + fontSize: 12, + fontFamily: 'JetBrainsMono'), + ), + const SizedBox(width: 6), + Text( + '-${file.deletions}', + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12, + fontFamily: 'JetBrainsMono'), + ), + const SizedBox(width: 4), + const Icon(Icons.expand_more, color: Colors.grey, size: 16), + ], + ), + children: file.hunks + .map((h) => DiffHunkView(hunk: h, viewMode: viewMode)) + .toList(), + ), + ); + } +} diff --git a/apps/mobile/lib/features/diff/presentation/widgets/diff_hunk_view.dart b/apps/mobile/lib/features/diff/presentation/widgets/diff_hunk_view.dart new file mode 100644 index 0000000..a10deee --- /dev/null +++ b/apps/mobile/lib/features/diff/presentation/widgets/diff_hunk_view.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../domain/providers/diff_provider.dart'; +import 'diff_line_widget.dart'; + +class DiffHunkView extends StatelessWidget { + final DiffHunk hunk; + final DiffViewMode viewMode; + + const DiffHunkView({ + super.key, + required this.hunk, + required this.viewMode, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hunk header + Container( + width: double.infinity, + color: const Color(0xFF1E1E1E), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + hunk.header, + style: const TextStyle( + color: Colors.grey, + fontFamily: 'JetBrainsMono', + fontSize: 12, + ), + ), + ), + // Lines + ...hunk.lines.map((line) => DiffLineWidget(line: line)), + ], + ); + } +} diff --git a/apps/mobile/lib/features/diff/presentation/widgets/diff_line_widget.dart b/apps/mobile/lib/features/diff/presentation/widgets/diff_line_widget.dart new file mode 100644 index 0000000..8c26f73 --- /dev/null +++ b/apps/mobile/lib/features/diff/presentation/widgets/diff_line_widget.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; + +class DiffLineWidget extends StatelessWidget { + final DiffLine line; + const DiffLineWidget({super.key, required this.line}); + + Color get _backgroundColor { + return switch (line.type) { + DiffLineType.added => const Color(0xFF1A3A1A), + DiffLineType.removed => const Color(0xFF3A1A1A), + DiffLineType.context => Colors.transparent, + }; + } + + String get _prefix { + return switch (line.type) { + DiffLineType.added => '+', + DiffLineType.removed => '-', + DiffLineType.context => ' ', + }; + } + + Color get _prefixColor { + return switch (line.type) { + DiffLineType.added => const Color(0xFF4EC9B0), + DiffLineType.removed => Colors.redAccent, + DiffLineType.context => Colors.grey, + }; + } + + @override + Widget build(BuildContext context) { + return Container( + color: _backgroundColor, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Old line number gutter + SizedBox( + width: 36, + child: Text( + line.oldLineNumber?.toString() ?? '', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.grey, + fontFamily: 'JetBrainsMono', + fontSize: 11, + ), + ), + ), + // New line number gutter + SizedBox( + width: 36, + child: Text( + line.newLineNumber?.toString() ?? '', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.grey, + fontFamily: 'JetBrainsMono', + fontSize: 11, + ), + ), + ), + // Prefix (+/-/space) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + _prefix, + style: TextStyle( + color: _prefixColor, + fontFamily: 'JetBrainsMono', + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + // Content + Expanded( + child: Text( + line.content, + style: TextStyle( + color: line.type == DiffLineType.context + ? const Color(0xFFD4D4D4) + : Colors.white, + fontFamily: 'JetBrainsMono', + fontSize: 12, + ), + softWrap: true, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/diff/presentation/widgets/diff_viewer.dart b/apps/mobile/lib/features/diff/presentation/widgets/diff_viewer.dart new file mode 100644 index 0000000..24b1dc6 --- /dev/null +++ b/apps/mobile/lib/features/diff/presentation/widgets/diff_viewer.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../domain/providers/diff_provider.dart'; +import 'diff_file_card.dart'; + +class DiffViewer extends StatelessWidget { + final List files; + final DiffViewMode viewMode; + + const DiffViewer({ + super.key, + required this.files, + required this.viewMode, + }); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: files.length, + itemBuilder: (context, i) => DiffFileCard( + file: files[i], + viewMode: viewMode, + ), + ); + } +} diff --git a/apps/mobile/lib/features/git/domain/providers/git_provider.dart b/apps/mobile/lib/features/git/domain/providers/git_provider.dart new file mode 100644 index 0000000..bba04d0 --- /dev/null +++ b/apps/mobile/lib/features/git/domain/providers/git_provider.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/providers/websocket_provider.dart'; +import '../../../diff/domain/providers/diff_provider.dart'; + +// --------------------------------------------------------------------------- +// GitStatus provider (AsyncNotifierProvider.family) +// --------------------------------------------------------------------------- + +final gitStatusProvider = AsyncNotifierProvider.family( + GitNotifier.new, +); + +class GitNotifier extends FamilyAsyncNotifier { + StreamSubscription? _sub; + + @override + Future build(String arg) async { + final service = ref.watch(webSocketServiceProvider); + + _sub?.cancel(); + _sub = service.messages.listen(_handleMessage); + ref.onDispose(() => _sub?.cancel()); + + return null; + } + + void _handleMessage(BridgeMessage msg) { + if (msg.type == BridgeMessageType.gitStatusResponse) { + final payload = msg.payload; + try { + final status = GitStatus.fromJson(payload); + state = AsyncValue.data(status); + } catch (_) { + // Malformed payload — ignore. + } + } + + if (msg.type == BridgeMessageType.gitDiffResponse) { + final rawFiles = msg.payload['files'] as List? ?? []; + final files = rawFiles + .whereType>() + .map(DiffFile.fromJson) + .toList(); + ref.read(currentDiffProvider.notifier).state = files; + } + } + + /// Sends a `git_status_request` and awaits a `git_status_response`. + Future fetchStatus(String sessionId) async { + state = const AsyncValue.loading(); + final service = ref.read(webSocketServiceProvider); + service.send(BridgeMessage.gitStatusRequest(sessionId: sessionId)); + // State will be updated by _handleMessage on response. + } + + /// Sends a `git_commit` message. + Future commit( + String sessionId, + String message, [ + List? files, + ]) async { + final service = ref.read(webSocketServiceProvider); + service.send(BridgeMessage.gitCommit( + sessionId: sessionId, + commitMessage: message, + files: files, + )); + } + + /// Sends a `git_diff` request; response populates [currentDiffProvider]. + Future fetchDiff(String sessionId) async { + final service = ref.read(webSocketServiceProvider); + service.send(BridgeMessage.gitDiff(sessionId: sessionId)); + } + + /// Sends a `git_pull` message. + Future pull(String sessionId) async { + final service = ref.read(webSocketServiceProvider); + // git_pull is not a first-class BridgeMessageType; tunnel via claudeEvent. + service.send(BridgeMessage( + type: BridgeMessageType.claudeEvent, + timestamp: DateTime.now().toUtc(), + payload: {'type': 'git_pull', 'session_id': sessionId}, + )); + } + + /// Sends a `git_push` message. + Future push(String sessionId) async { + final service = ref.read(webSocketServiceProvider); + service.send(BridgeMessage( + type: BridgeMessageType.claudeEvent, + timestamp: DateTime.now().toUtc(), + payload: {'type': 'git_push', 'session_id': sessionId}, + )); + } +} diff --git a/apps/mobile/lib/features/git/presentation/screens/commit_screen.dart b/apps/mobile/lib/features/git/presentation/screens/commit_screen.dart new file mode 100644 index 0000000..09c87fe --- /dev/null +++ b/apps/mobile/lib/features/git/presentation/screens/commit_screen.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/git_models.dart'; +import '../../domain/providers/git_provider.dart'; + +/// Commit screen: compose a commit message and select which changed files +/// to include in the commit. +class CommitScreen extends ConsumerStatefulWidget { + final String sessionId; + final List changes; + + const CommitScreen({ + super.key, + required this.sessionId, + required this.changes, + }); + + @override + ConsumerState createState() => _CommitScreenState(); +} + +class _CommitScreenState extends ConsumerState { + final _messageController = TextEditingController(); + late final List _checked; + bool _isCommitting = false; + + @override + void initState() { + super.initState(); + _checked = List.filled(widget.changes.length, true); + _messageController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + bool get _canCommit => + _messageController.text.trim().isNotEmpty && !_isCommitting; + + Future _commit() async { + if (!_canCommit) return; + setState(() => _isCommitting = true); + + final selectedFiles = []; + for (var i = 0; i < widget.changes.length; i++) { + if (_checked[i]) selectedFiles.add(widget.changes[i].path); + } + + await ref + .read(gitStatusProvider(widget.sessionId).notifier) + .commit( + widget.sessionId, + _messageController.text.trim(), + selectedFiles.isEmpty ? null : selectedFiles, + ); + + if (mounted) { + setState(() => _isCommitting = false); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Commit sent')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Commit'), + actions: [ + TextButton( + onPressed: _canCommit ? _commit : null, + child: _isCommitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Commit'), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _messageController, + minLines: 3, + maxLines: 6, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Commit message…', + alignLabelWithHint: true, + ), + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: widget.changes.length, + itemBuilder: (context, index) { + final change = widget.changes[index]; + return CheckboxListTile( + dense: true, + value: _checked[index], + onChanged: (v) => + setState(() => _checked[index] = v ?? false), + title: Text( + change.path, + style: const TextStyle( + fontSize: 13, + fontFamily: 'JetBrainsMono', + ), + overflow: TextOverflow.ellipsis, + ), + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/git/presentation/screens/git_screen.dart b/apps/mobile/lib/features/git/presentation/screens/git_screen.dart new file mode 100644 index 0000000..7ff4dec --- /dev/null +++ b/apps/mobile/lib/features/git/presentation/screens/git_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/providers/git_provider.dart'; +import '../widgets/file_change_tile.dart'; +import '../widgets/git_status_card.dart'; + +/// Main Git screen showing repository status and a list of changed files. +/// +/// Requires a [sessionId] to scope WS requests. The session ID is taken from +/// the `extra` field of the route or falls back to an empty string for +/// demonstration purposes when navigated from the bottom nav. +class GitScreen extends ConsumerStatefulWidget { + final String sessionId; + + const GitScreen({super.key, this.sessionId = ''}); + + @override + ConsumerState createState() => _GitScreenState(); +} + +class _GitScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + if (widget.sessionId.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(gitStatusProvider(widget.sessionId).notifier) + .fetchStatus(widget.sessionId); + }); + } + } + + Future _refresh() async { + if (widget.sessionId.isNotEmpty) { + await ref + .read(gitStatusProvider(widget.sessionId).notifier) + .fetchStatus(widget.sessionId); + } + } + + void _onPull() { + if (widget.sessionId.isNotEmpty) { + ref + .read(gitStatusProvider(widget.sessionId).notifier) + .pull(widget.sessionId); + } + } + + void _onPush() { + if (widget.sessionId.isNotEmpty) { + ref + .read(gitStatusProvider(widget.sessionId).notifier) + .push(widget.sessionId); + } + } + + void _onCommit(List changes) { + context.push( + '/git/commit', + extra: {'sessionId': widget.sessionId, 'changes': changes}, + ); + } + + @override + Widget build(BuildContext context) { + final statusAsync = widget.sessionId.isNotEmpty + ? ref.watch(gitStatusProvider(widget.sessionId)) + : const AsyncValue.data(null); + + return Scaffold( + appBar: AppBar(title: const Text('Git')), + body: RefreshIndicator( + onRefresh: _refresh, + child: statusAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (status) { + if (status == null) { + return _emptyState(); + } + + if (status.isClean) { + return Column( + children: [ + GitStatusCard(status: status), + Expanded(child: _emptyState()), + ], + ); + } + + return Column( + children: [ + GitStatusCard(status: status), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: status.changes.length, + itemBuilder: (context, index) { + final change = status.changes[index]; + return FileChangeTile( + change: change, + onTap: () {}, + ); + }, + ), + ), + _ActionRow( + onPull: _onPull, + onCommit: () => _onCommit(status.changes), + onPush: _onPush, + ), + ], + ); + }, + ), + ), + ); + } + + Widget _emptyState() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.check_circle_outline, + size: 48, color: Color(0xFF4CAF50)), + SizedBox(height: 12), + Text( + 'Repository is clean', + style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E)), + ), + ], + ), + ); + } +} + +class _ActionRow extends StatelessWidget { + final VoidCallback onPull; + final VoidCallback onCommit; + final VoidCallback onPush; + + const _ActionRow({ + required this.onPull, + required this.onCommit, + required this.onPush, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF1E1E1E), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onPull, + icon: const Icon(Icons.download_outlined, size: 16), + label: const Text('Pull'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: onCommit, + icon: const Icon(Icons.commit, size: 16), + label: const Text('Commit'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: onPush, + icon: const Icon(Icons.upload_outlined, size: 16), + label: const Text('Push'), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/git/presentation/widgets/file_change_tile.dart b/apps/mobile/lib/features/git/presentation/widgets/file_change_tile.dart new file mode 100644 index 0000000..144a6d4 --- /dev/null +++ b/apps/mobile/lib/features/git/presentation/widgets/file_change_tile.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; + +/// A list tile for a single [GitFileChange]. +class FileChangeTile extends StatelessWidget { + final GitFileChange change; + final VoidCallback? onTap; + + const FileChangeTile({ + super.key, + required this.change, + this.onTap, + }); + + String _statusLabel(FileChangeStatus status) { + return switch (status) { + FileChangeStatus.modified => 'M', + FileChangeStatus.added => 'A', + FileChangeStatus.deleted => 'D', + FileChangeStatus.renamed => 'R', + FileChangeStatus.untracked => '?', + }; + } + + Color _statusColor(FileChangeStatus status) { + return switch (status) { + FileChangeStatus.modified => const Color(0xFF569CD6), + FileChangeStatus.added => const Color(0xFF4EC9B0), + FileChangeStatus.deleted => const Color(0xFFF44747), + FileChangeStatus.renamed => const Color(0xFFFF9800), + FileChangeStatus.untracked => const Color(0xFF9E9E9E), + }; + } + + @override + Widget build(BuildContext context) { + final color = _statusColor(change.status); + final label = _statusLabel(change.status); + + final hasStats = + change.additions != null || change.deletions != null; + + return ListTile( + onTap: onTap, + dense: true, + leading: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.4)), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + title: Text( + change.path, + style: const TextStyle( + fontSize: 13, + fontFamily: 'JetBrainsMono', + color: Color(0xFFD4D4D4), + ), + overflow: TextOverflow.ellipsis, + ), + subtitle: hasStats + ? Text( + [ + if (change.additions != null) '+${change.additions}', + if (change.deletions != null) '-${change.deletions}', + ].join(' '), + style: const TextStyle( + fontSize: 11, + fontFamily: 'JetBrainsMono', + color: Color(0xFF9E9E9E), + ), + ) + : null, + trailing: onTap != null + ? const Icon(Icons.chevron_right, + size: 18, color: Color(0xFF9E9E9E)) + : null, + ); + } +} diff --git a/apps/mobile/lib/features/git/presentation/widgets/git_status_card.dart b/apps/mobile/lib/features/git/presentation/widgets/git_status_card.dart new file mode 100644 index 0000000..0ffa484 --- /dev/null +++ b/apps/mobile/lib/features/git/presentation/widgets/git_status_card.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/git_models.dart'; + +/// Card showing branch name, ahead/behind chip counts, and a clean/dirty badge. +class GitStatusCard extends StatelessWidget { + final GitStatus status; + + const GitStatusCard({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.all(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Icon(Icons.account_tree_outlined, + size: 18, color: Color(0xFF569CD6)), + const SizedBox(width: 8), + Expanded( + child: Text( + status.branch, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFFD4D4D4), + ), + ), + ), + if (status.ahead > 0) ...[ + _Chip( + label: '↑ ${status.ahead}', + color: const Color(0xFF4EC9B0), + ), + const SizedBox(width: 6), + ], + if (status.behind > 0) ...[ + _Chip( + label: '↓ ${status.behind}', + color: const Color(0xFFFF9800), + ), + const SizedBox(width: 6), + ], + _Chip( + label: status.isClean ? 'Clean' : 'Dirty', + color: status.isClean + ? const Color(0xFF4CAF50) + : const Color(0xFFF44747), + ), + ], + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + final String label; + final Color color; + + const _Chip({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Text( + label, + style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.w500), + ), + ); + } +} diff --git a/apps/mobile/lib/features/home/home_shell.dart b/apps/mobile/lib/features/home/home_shell.dart new file mode 100644 index 0000000..0cd6c5f --- /dev/null +++ b/apps/mobile/lib/features/home/home_shell.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../shared/widgets/connection_status_bar.dart'; + +/// Root shell widget that wraps the bottom-navigation branches. +/// +/// This stays feature-owned while preserving the existing router behavior. +class HomeShell extends StatelessWidget { + const HomeShell({super.key, required this.navigationShell}); + + final StatefulNavigationShell navigationShell; + + static const List _items = [ + BottomNavigationBarItem(icon: Icon(Icons.chat), label: 'Chat'), + BottomNavigationBarItem(icon: Icon(Icons.difference), label: 'Diff'), + BottomNavigationBarItem( + icon: Icon(Icons.folder_outlined), + label: 'Files', + ), + BottomNavigationBarItem(icon: Icon(Icons.source), label: 'Git'), + BottomNavigationBarItem( + icon: Icon(Icons.approval), + label: 'Approvals', + ), + BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + const ConnectionStatusBar(), + Expanded(child: navigationShell), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + items: _items, + onTap: (index) => navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/repos/domain/providers/repo_provider.dart b/apps/mobile/lib/features/repos/domain/providers/repo_provider.dart new file mode 100644 index 0000000..6947540 --- /dev/null +++ b/apps/mobile/lib/features/repos/domain/providers/repo_provider.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/file_models.dart'; +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/network/websocket_service.dart'; +import '../../../../core/providers/websocket_provider.dart'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +class RepoState { + final String currentPath; + final List nodes; + final bool isLoading; + final String? error; + final bool showHidden; + + const RepoState({ + this.currentPath = '.', + this.nodes = const [], + this.isLoading = false, + this.error, + this.showHidden = false, + }); + + RepoState copyWith({ + String? currentPath, + List? nodes, + bool? isLoading, + String? error, + bool? showHidden, + bool clearError = false, + }) { + return RepoState( + currentPath: currentPath ?? this.currentPath, + nodes: nodes ?? this.nodes, + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + showHidden: showHidden ?? this.showHidden, + ); + } + + /// Path segments for breadcrumb display. + List get breadcrumbs { + if (currentPath == '.' || currentPath == '/') return ['/']; + final normalised = currentPath.replaceAll('\\', '/'); + final parts = normalised.split('/').where((s) => s.isNotEmpty).toList(); + return ['/', ...parts]; + } + + /// Visible nodes, filtered by [showHidden]. + List get visibleNodes { + if (showHidden) return nodes; + return nodes.where((n) => !n.name.startsWith('.')).toList(); + } + + bool get isAtRoot { + return currentPath == '.' || currentPath == '/' || currentPath.isEmpty; + } +} + +// --------------------------------------------------------------------------- +// Notifier +// --------------------------------------------------------------------------- + +class RepoNotifier extends FamilyAsyncNotifier { + /// The sessionId passed as the family argument. + String get _sessionId => arg; + + @override + Future build(String arg) async { + // Listen to inbound bridge messages and surface file_list / file_read + // responses to waiting completers. + ref.listen>(bridgeMessagesProvider, (_, next) { + next.whenData(_handleMessage); + }); + + // Start at root of session working directory. + final initial = const RepoState(); + await _fetchDirectory(initial.currentPath); + return state.value ?? initial; + } + + // --------------------------------------------------------------------------- + // Response correlators + // --------------------------------------------------------------------------- + + final Map> _pending = {}; + + void _handleMessage(BridgeMessage msg) { + if (msg.id != null && _pending.containsKey(msg.id)) { + _pending[msg.id]!.complete(msg); + _pending.remove(msg.id); + return; + } + // Unsolicited file_list_response — ignore (another session may have sent). + } + + Future _sendAndAwait(BridgeMessage outgoing) { + final completer = Completer(); + if (outgoing.id != null) { + _pending[outgoing.id!] = completer; + } + final service = ref.read(webSocketServiceProvider); + service.send(outgoing); + + // 30-second timeout to avoid leaking completers. + Future.delayed(const Duration(seconds: 30), () { + if (!completer.isCompleted) { + _pending.remove(outgoing.id); + completer.completeError(TimeoutException('Bridge response timed out')); + } + }); + + return completer.future; + } + + // --------------------------------------------------------------------------- + // Public actions + // --------------------------------------------------------------------------- + + /// Fetch [path] and update state with the returned nodes. + Future fetchDirectory(String path) async { + final current = state.value ?? const RepoState(); + state = AsyncData(current.copyWith(isLoading: true, clearError: true)); + await _fetchDirectory(path); + } + + Future _fetchDirectory(String path) async { + final current = state.value ?? const RepoState(); + try { + final outgoing = BridgeMessage.fileList( + sessionId: _sessionId, + path: path, + ); + final response = await _sendAndAwait(outgoing); + + if (response.type == BridgeMessageType.error) { + final msg = response.payload['message'] as String? ?? 'Unknown error'; + state = AsyncData( + current.copyWith( + isLoading: false, + error: msg, + currentPath: path, + ), + ); + return; + } + + final rawNodes = response.payload['nodes'] as List? ?? []; + final nodes = rawNodes + .whereType>() + .map(FileTreeNode.fromJson) + .toList(); + + // Sort: directories first, then files, each group alphabetically. + nodes.sort((a, b) { + if (a.type != b.type) { + return a.type == FileNodeType.directory ? -1 : 1; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + state = AsyncData( + current.copyWith( + currentPath: path, + nodes: nodes, + isLoading: false, + clearError: true, + ), + ); + } catch (e) { + state = AsyncData( + current.copyWith( + isLoading: false, + error: e.toString(), + ), + ); + } + } + + /// Fetch the content of a file at [path]. + Future readFile(String path) async { + final outgoing = BridgeMessage.fileRead( + sessionId: _sessionId, + path: path, + ); + final response = await _sendAndAwait(outgoing); + if (response.type == BridgeMessageType.error) { + final msg = response.payload['message'] as String? ?? 'Unknown error'; + throw Exception(msg); + } + return response.payload['content'] as String? ?? ''; + } + + /// Navigate to [path], updating the current directory. + Future navigateTo(String path) => fetchDirectory(path); + + /// Navigate to the parent directory. + Future navigateUp() { + final current = state.value?.currentPath ?? '.'; + if (current == '.' || current == '/' || current.isEmpty) return Future.value(); + + final normalised = current.replaceAll('\\', '/'); + final segments = normalised.split('/').where((s) => s.isNotEmpty).toList(); + if (segments.isEmpty) return Future.value(); + segments.removeLast(); + final parent = segments.isEmpty ? '/' : segments.join('/'); + return fetchDirectory(parent); + } + + /// Toggle display of hidden (dot-prefixed) files. + void toggleHidden() { + final current = state.value ?? const RepoState(); + state = AsyncData(current.copyWith(showHidden: !current.showHidden)); + } +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +/// Family notifier provider keyed by sessionId. +final repoProvider = + AsyncNotifierProviderFamily( + RepoNotifier.new, +); + +/// Provides the content string of a file at [path] for a given [sessionId]. +final fileContentProvider = + FutureProviderFamily( + (ref, args) async { + final notifier = ref.read(repoProvider(args.sessionId).notifier); + return notifier.readFile(args.path); + }, +); diff --git a/apps/mobile/lib/features/repos/presentation/screens/file_tree_screen.dart b/apps/mobile/lib/features/repos/presentation/screens/file_tree_screen.dart new file mode 100644 index 0000000..3f3ec5b --- /dev/null +++ b/apps/mobile/lib/features/repos/presentation/screens/file_tree_screen.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/models/file_models.dart'; +import '../../../../shared/widgets/empty_state.dart'; +import '../../../../shared/widgets/error_card.dart'; +import '../../../../shared/widgets/loading_indicator.dart'; +import '../../domain/providers/repo_provider.dart'; +import '../widgets/breadcrumb_nav.dart'; +import '../widgets/file_tree_node_widget.dart'; + +/// Full-screen file tree browser for a given [sessionId]. +class FileTreeScreen extends ConsumerWidget { + final String sessionId; + + const FileTreeScreen({super.key, required this.sessionId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repoAsync = ref.watch(repoProvider(sessionId)); + final notifier = ref.read(repoProvider(sessionId).notifier); + + return repoAsync.when( + loading: () => Scaffold( + appBar: _buildAppBar(context, ref, '…', notifier), + body: const LoadingIndicator(message: 'Loading directory…'), + ), + error: (err, _) => Scaffold( + appBar: _buildAppBar(context, ref, 'Error', notifier), + body: Center( + child: ErrorCard( + message: err.toString(), + onRetry: () => notifier.fetchDirectory( + repoAsync.value?.currentPath ?? '.', + ), + ), + ), + ), + data: (state) { + final nodes = state.visibleNodes; + + return Scaffold( + backgroundColor: const Color(0xFF121212), + appBar: _buildAppBar(context, ref, state.currentPath, notifier), + floatingActionButton: FloatingActionButton.small( + tooltip: state.showHidden ? 'Hide hidden files' : 'Show hidden files', + onPressed: notifier.toggleHidden, + child: Icon( + state.showHidden + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + body: Column( + children: [ + // Breadcrumb + Container( + color: const Color(0xFF1E1E1E), + child: BreadcrumbNav( + path: state.currentPath, + onSegmentTap: notifier.navigateTo, + ), + ), + const Divider(height: 1, color: Color(0xFF3E3E3E)), + // Loading overlay when fetching a sub-directory + if (state.isLoading) + const LinearProgressIndicator(minHeight: 2), + // Error banner + if (state.error != null) + Padding( + padding: const EdgeInsets.all(8), + child: ErrorCard( + message: state.error!, + onRetry: () => notifier.fetchDirectory(state.currentPath), + ), + ), + // File list + Expanded( + child: nodes.isEmpty && !state.isLoading + ? EmptyState( + icon: Icons.folder_open, + title: 'Empty directory', + subtitle: state.showHidden + ? null + : 'Toggle visibility to show hidden files.', + ) + : RefreshIndicator( + onRefresh: () => + notifier.fetchDirectory(state.currentPath), + child: ListView.builder( + itemCount: nodes.length, + itemBuilder: (context, index) { + final node = nodes[index]; + return FileTreeNodeWidget( + node: node, + onTap: () => _onNodeTap(context, node), + ); + }, + ), + ), + ), + ], + ), + ); + }, + ); + } + + AppBar _buildAppBar( + BuildContext context, + WidgetRef ref, + String currentPath, + RepoNotifier notifier, + ) { + final state = ref.read(repoProvider(sessionId)).value; + final atRoot = state?.isAtRoot ?? true; + + return AppBar( + backgroundColor: const Color(0xFF1E1E1E), + leading: atRoot + ? null + : IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: notifier.navigateUp, + ), + title: Text( + currentPath, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFFD4D4D4), + ), + overflow: TextOverflow.ellipsis, + ), + ); + } + + void _onNodeTap(BuildContext context, FileTreeNode node) { + final notifier = + // ignore: invalid_use_of_protected_member + ProviderScope.containerOf(context).read(repoProvider(sessionId).notifier); + if (node.type == FileNodeType.directory) { + notifier.navigateTo(node.path); + } else { + context.push( + '/home/repos/view', + extra: {'path': node.path, 'sessionId': sessionId}, + ); + } + } +} diff --git a/apps/mobile/lib/features/repos/presentation/screens/file_viewer_screen.dart b/apps/mobile/lib/features/repos/presentation/screens/file_viewer_screen.dart new file mode 100644 index 0000000..2616fa3 --- /dev/null +++ b/apps/mobile/lib/features/repos/presentation/screens/file_viewer_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../shared/widgets/error_card.dart'; +import '../../../../shared/widgets/loading_indicator.dart'; +import '../../domain/providers/repo_provider.dart'; +import '../widgets/syntax_highlighted_file.dart'; + +/// Displays the content of a single file fetched via the bridge. +class FileViewerScreen extends ConsumerWidget { + final String sessionId; + final String path; + + const FileViewerScreen({ + super.key, + required this.sessionId, + required this.path, + }); + + String get _filename { + final normalised = path.replaceAll('\\', '/'); + return normalised.split('/').last; + } + + int? _fileSizeFromState(WidgetRef ref) { + final state = ref.read(repoProvider(sessionId)).value; + if (state == null) return null; + try { + return state.nodes + .firstWhere((n) => n.path == path) + .size; + } catch (_) { + return null; + } + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + final kb = bytes / 1024; + return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB'; + } + final mb = bytes / (1024 * 1024); + return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB'; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final contentAsync = ref.watch( + fileContentProvider((sessionId: sessionId, path: path)), + ); + final knownSize = _fileSizeFromState(ref); + + return Scaffold( + backgroundColor: const Color(0xFF121212), + appBar: AppBar( + backgroundColor: const Color(0xFF1E1E1E), + title: Row( + children: [ + Expanded( + child: Text( + _filename, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFFD4D4D4), + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (knownSize != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatSize(knownSize), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + fontFamily: 'JetBrainsMono', + ), + ), + ), + ], + ], + ), + actions: [ + contentAsync.whenOrNull( + data: (content) => IconButton( + tooltip: 'Copy file', + icon: const Icon(Icons.copy_outlined), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: content)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + }, + ), + ) ?? + const SizedBox.shrink(), + ], + ), + body: contentAsync.when( + loading: () => + const LoadingIndicator(message: 'Fetching file content…'), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: ErrorCard( + message: err.toString(), + onRetry: () => ref.invalidate( + fileContentProvider((sessionId: sessionId, path: path)), + ), + ), + ), + ), + data: (content) { + final lineCount = '\n'.allMatches(content).length + 1; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (lineCount > 1000) + Container( + color: const Color(0xFF2D2D2D), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 14, + color: Color(0xFFFF9800), + ), + const SizedBox(width: 6), + Text( + 'Large file — $lineCount lines.', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFFF9800), + ), + ), + ], + ), + ), + Expanded( + child: SyntaxHighlightedFile( + content: content, + filePath: path, + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/features/repos/presentation/widgets/breadcrumb_nav.dart b/apps/mobile/lib/features/repos/presentation/widgets/breadcrumb_nav.dart new file mode 100644 index 0000000..ff7e0d5 --- /dev/null +++ b/apps/mobile/lib/features/repos/presentation/widgets/breadcrumb_nav.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/constants/colors.dart'; + +/// Horizontal scrollable breadcrumb navigation for a file path. +/// +/// [path] is split on `/` and `\` into segments. Each segment is rendered as +/// a tappable [TextButton]. Tapping a segment calls [onSegmentTap] with the +/// cumulative path up to and including that segment. The last (current) +/// segment is rendered in bold primary colour. Adjacent segments are separated +/// by a small chevron icon. The row auto-scrolls to the trailing end whenever +/// [path] changes. +class BreadcrumbNav extends StatefulWidget { + final String path; + final void Function(String path) onSegmentTap; + + const BreadcrumbNav({ + super.key, + required this.path, + required this.onSegmentTap, + }); + + @override + State createState() => _BreadcrumbNavState(); +} + +class _BreadcrumbNavState extends State { + final _scrollController = ScrollController(); + + @override + void didUpdateWidget(BreadcrumbNav oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.path != widget.path) { + // Scroll to the end after layout completes. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + List<_Segment> _buildSegments(String path) { + final normalised = path.replaceAll('\\', '/'); + final parts = normalised.split('/').where((s) => s.isNotEmpty).toList(); + + final segments = <_Segment>[]; + // Root segment. + segments.add(_Segment(label: '/', cumulativePath: '/')); + + for (var i = 0; i < parts.length; i++) { + final cumulative = '/${parts.sublist(0, i + 1).join('/')}'; + segments.add(_Segment(label: parts[i], cumulativePath: cumulative)); + } + + return segments; + } + + @override + Widget build(BuildContext context) { + final segments = _buildSegments(widget.path); + final lastIndex = segments.length - 1; + + return SizedBox( + height: 40, + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: segments.length * 2 - 1, + padding: const EdgeInsets.symmetric(horizontal: 4), + itemBuilder: (context, index) { + // Even indices → segment buttons, odd indices → separators. + if (index.isOdd) { + return const Icon( + Icons.chevron_right, + size: 16, + color: Color(0xFF9E9E9E), + ); + } + final segIndex = index ~/ 2; + final seg = segments[segIndex]; + final isCurrent = segIndex == lastIndex; + + return TextButton.icon( + onPressed: () => widget.onSegmentTap(seg.cumulativePath), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: segIndex == 0 + ? const Icon(Icons.folder, size: 14, color: kTextSecondary) + : const SizedBox.shrink(), + label: Text( + seg.label, + style: TextStyle( + fontSize: 13, + fontWeight: + isCurrent ? FontWeight.w700 : FontWeight.w400, + color: isCurrent ? kPrimary : kTextSecondary, + ), + ), + ); + }, + ), + ); + } +} + +class _Segment { + final String label; + final String cumulativePath; + + const _Segment({required this.label, required this.cumulativePath}); +} diff --git a/apps/mobile/lib/features/repos/presentation/widgets/file_tree_node_widget.dart b/apps/mobile/lib/features/repos/presentation/widgets/file_tree_node_widget.dart new file mode 100644 index 0000000..8985acb --- /dev/null +++ b/apps/mobile/lib/features/repos/presentation/widgets/file_tree_node_widget.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/file_models.dart'; +import '../../../../shared/constants/colors.dart'; +import '../../../../shared/constants/typography.dart'; + +/// A single row in the file tree list. +/// +/// Shows an appropriate icon and colour for the node type/extension, the +/// node name in monospace, and — for files — a human-readable size badge. +class FileTreeNodeWidget extends StatelessWidget { + final FileTreeNode node; + final VoidCallback onTap; + + const FileTreeNodeWidget({ + super.key, + required this.node, + required this.onTap, + }); + + // --------------------------------------------------------------------------- + // Icon helpers + // --------------------------------------------------------------------------- + + static IconData _iconForNode(FileTreeNode node) { + if (node.type == FileNodeType.directory) return Icons.folder; + final ext = _extension(node.name); + return switch (ext) { + 'dart' => Icons.code, + 'ts' || 'tsx' || 'js' || 'jsx' => Icons.javascript, + 'md' || 'mdx' => Icons.description, + 'json' => Icons.data_object, + 'yaml' || 'yml' => Icons.settings, + 'png' || 'jpg' || 'jpeg' || 'gif' || 'svg' || 'webp' => Icons.image, + _ => Icons.insert_drive_file, + }; + } + + static Color _colorForNode(FileTreeNode node) { + if (node.type == FileNodeType.directory) { + return const Color(0xFFE8C17A); // warm yellow for folders + } + final ext = _extension(node.name); + return switch (ext) { + 'dart' => kPrimary, // blue + 'ts' || 'tsx' || 'js' || 'jsx' => const Color(0xFFE8C17A), // yellow + 'md' || 'mdx' => const Color(0xFF4EC9B0), // teal + 'json' || 'yaml' || 'yml' || 'toml' || 'ini' => kTextSecondary, // grey + _ => kTextSecondary, + }; + } + + static String _extension(String name) { + final dot = name.lastIndexOf('.'); + if (dot == -1 || dot == name.length - 1) return ''; + return name.substring(dot + 1).toLowerCase(); + } + + // --------------------------------------------------------------------------- + // Size formatting + // --------------------------------------------------------------------------- + + static String _formatSize(int bytes) { + if (bytes < 1024) return '${bytes} B'; + if (bytes < 1024 * 1024) { + final kb = bytes / 1024; + return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB'; + } + final mb = bytes / (1024 * 1024); + return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB'; + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final isDir = node.type == FileNodeType.directory; + final iconColor = _colorForNode(node); + final icon = _iconForNode(node); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + dense: true, + leading: Icon(icon, color: iconColor, size: 20), + title: Text( + node.name, + style: AppTypography.code.copyWith( + fontSize: 13, + color: const Color(0xFFD4D4D4), + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isDir && node.size != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatSize(node.size!), + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9E9E9E), + fontFamily: 'JetBrainsMono', + ), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right, + size: 16, + color: Color(0xFF9E9E9E), + ), + ], + ), + onTap: onTap, + ), + const Divider(height: 1, thickness: 1, color: Color(0xFF2A2A2A)), + ], + ); + } +} diff --git a/apps/mobile/lib/features/repos/presentation/widgets/syntax_highlighted_file.dart b/apps/mobile/lib/features/repos/presentation/widgets/syntax_highlighted_file.dart new file mode 100644 index 0000000..69f1dfc --- /dev/null +++ b/apps/mobile/lib/features/repos/presentation/widgets/syntax_highlighted_file.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../shared/constants/typography.dart'; + +// --------------------------------------------------------------------------- +// Syntax colour constants (VS Code Dark+ palette) +// --------------------------------------------------------------------------- + +const _colDefault = Color(0xFFD4D4D4); +const _colKeyword = Color(0xFF569CD6); +const _colString = Color(0xFFCE9178); +const _colComment = Color(0xFF6A9955); +const _colNumber = Color(0xFFB5CEA8); +const _colType = Color(0xFF4EC9B0); + +// --------------------------------------------------------------------------- +// Token model +// --------------------------------------------------------------------------- + +enum _TokenKind { comment, string, keyword, number, type, plain } + +class _Token { + final String text; + final _TokenKind kind; + + const _Token(this.text, this.kind); + + Color get color => switch (kind) { + _TokenKind.comment => _colComment, + _TokenKind.string => _colString, + _TokenKind.keyword => _colKeyword, + _TokenKind.number => _colNumber, + _TokenKind.type => _colType, + _TokenKind.plain => _colDefault, + }; +} + +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- + +const _dartKeywords = { + 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', + 'return', 'class', 'extends', 'implements', 'mixin', 'with', 'abstract', + 'import', 'export', 'const', 'final', 'var', 'late', 'required', 'async', + 'await', 'yield', 'try', 'catch', 'finally', 'throw', 'new', 'void', + 'null', 'true', 'false', 'this', 'super', 'static', 'enum', 'typedef', + 'get', 'set', 'in', 'is', 'as', 'part', 'library', 'show', 'hide', +}; + +const _jsKeywords = { + 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', + 'return', 'class', 'extends', 'import', 'export', 'const', 'let', 'var', + 'function', 'async', 'await', 'try', 'catch', 'finally', 'throw', 'new', + 'void', 'null', 'true', 'false', 'this', 'super', 'static', 'typeof', + 'instanceof', 'in', 'of', 'from', 'default', 'yield', +}; + +bool _isSyntaxLanguage(String ext) => + ext == 'dart' || ext == 'ts' || ext == 'tsx' || ext == 'js' || ext == 'jsx'; + +String _ext(String filePath) { + final dot = filePath.lastIndexOf('.'); + if (dot == -1 || dot == filePath.length - 1) return ''; + return filePath.substring(dot + 1).toLowerCase(); +} + +List<_Token> _tokeniseLine(String line, String ext) { + if (!_isSyntaxLanguage(ext)) return [_Token(line, _TokenKind.plain)]; + + final keywords = ext == 'dart' ? _dartKeywords : _jsKeywords; + final tokens = <_Token>[]; + + // State machine — process character by character. + var i = 0; + final buf = StringBuffer(); + + void flush({_TokenKind kind = _TokenKind.plain}) { + if (buf.isEmpty) return; + tokens.add(_Token(buf.toString(), kind)); + buf.clear(); + } + + while (i < line.length) { + // Single-line comment // + if (i + 1 < line.length && line[i] == '/' && line[i + 1] == '/') { + flush(); + tokens.add(_Token(line.substring(i), _TokenKind.comment)); + return tokens; + } + + // Block comment start /* (treat rest of line as comment for simplicity) + if (i + 1 < line.length && line[i] == '/' && line[i + 1] == '*') { + flush(); + final end = line.indexOf('*/', i + 2); + if (end == -1) { + tokens.add(_Token(line.substring(i), _TokenKind.comment)); + return tokens; + } + tokens.add(_Token(line.substring(i, end + 2), _TokenKind.comment)); + i = end + 2; + continue; + } + + // String literals " or ' + if (line[i] == '"' || line[i] == "'") { + flush(); + final quote = line[i]; + final sb = StringBuffer(quote); + i++; + while (i < line.length) { + if (line[i] == '\\' && i + 1 < line.length) { + sb.write(line[i]); + sb.write(line[i + 1]); + i += 2; + continue; + } + sb.write(line[i]); + if (line[i] == quote) { + i++; + break; + } + i++; + } + tokens.add(_Token(sb.toString(), _TokenKind.string)); + continue; + } + + // Template literal ` + if (line[i] == '`') { + flush(); + final end = line.indexOf('`', i + 1); + final close = end == -1 ? line.length : end + 1; + tokens.add(_Token(line.substring(i, close), _TokenKind.string)); + i = close; + continue; + } + + // Number literal + if (line[i].codeUnitAt(0) >= 48 && line[i].codeUnitAt(0) <= 57) { + flush(); + final start = i; + while (i < line.length && + (RegExp(r'[0-9._xXa-fA-F]').hasMatch(line[i]))) { + i++; + } + tokens.add(_Token(line.substring(start, i), _TokenKind.number)); + continue; + } + + // Word (keyword, type, or identifier) + if (RegExp(r'[a-zA-Z_$]').hasMatch(line[i])) { + flush(); + final start = i; + while (i < line.length && RegExp(r'[\w$]').hasMatch(line[i])) { + i++; + } + final word = line.substring(start, i); + _TokenKind kind; + if (keywords.contains(word)) { + kind = _TokenKind.keyword; + } else if (word.isNotEmpty && + word[0].toUpperCase() == word[0] && + word[0] != word[0].toLowerCase()) { + kind = _TokenKind.type; + } else { + kind = _TokenKind.plain; + } + tokens.add(_Token(word, kind)); + continue; + } + + buf.write(line[i]); + i++; + } + + flush(); + return tokens; +} + +// --------------------------------------------------------------------------- +// Widget +// --------------------------------------------------------------------------- + +const int _pageSize = 500; + +/// Scrollable syntax-highlighted file viewer with a line-number gutter. +/// +/// For files over [_pageSize] lines the first page is shown with a +/// "Load more" button at the bottom. +class SyntaxHighlightedFile extends StatefulWidget { + final String content; + final String filePath; + + const SyntaxHighlightedFile({ + super.key, + required this.content, + required this.filePath, + }); + + @override + State createState() => _SyntaxHighlightedFileState(); +} + +class _SyntaxHighlightedFileState extends State { + late List _allLines; + late int _visibleCount; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(SyntaxHighlightedFile old) { + super.didUpdateWidget(old); + if (old.content != widget.content || old.filePath != widget.filePath) { + _init(); + } + } + + void _init() { + _allLines = widget.content.split('\n'); + _visibleCount = _allLines.length.clamp(0, _pageSize); + } + + void _loadMore() { + setState(() { + _visibleCount = + (_visibleCount + _pageSize).clamp(0, _allLines.length); + }); + } + + Future _copyAll() async { + await Clipboard.setData(ClipboardData(text: widget.content)); + } + + @override + Widget build(BuildContext context) { + final ext = _ext(widget.filePath); + final totalLines = _allLines.length; + final gutterWidth = '$totalLines'.length * 9.0 + 12; + final showLargeWarning = totalLines > 1000 && _visibleCount <= _pageSize; + final hasMore = _visibleCount < totalLines; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showLargeWarning) + Container( + color: const Color(0xFF2D2D2D), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 14, + color: Color(0xFFFF9800), + ), + const SizedBox(width: 6), + Text( + 'Large file — $totalLines lines total, showing first $_visibleCount.', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFFF9800), + ), + ), + ], + ), + ), + Expanded( + child: Scrollbar( + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Code lines + for (var i = 0; i < _visibleCount; i++) + _CodeLine( + lineNumber: i + 1, + line: _allLines[i], + ext: ext, + gutterWidth: gutterWidth, + ), + // Load more button + if (hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: TextButton.icon( + onPressed: _loadMore, + icon: const Icon(Icons.expand_more, size: 16), + label: Text( + 'Load more (${totalLines - _visibleCount} remaining)', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Single code line +// --------------------------------------------------------------------------- + +class _CodeLine extends StatelessWidget { + final int lineNumber; + final String line; + final String ext; + final double gutterWidth; + + const _CodeLine({ + required this.lineNumber, + required this.line, + required this.ext, + required this.gutterWidth, + }); + + @override + Widget build(BuildContext context) { + final tokens = _tokeniseLine(line, ext); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Gutter + SizedBox( + width: gutterWidth, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Text( + '$lineNumber', + textAlign: TextAlign.right, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFF5A5A5A), + height: 1.5, + ), + ), + ), + ), + // Separator + Container( + width: 1, + color: const Color(0xFF2A2A2A), + margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + ), + // Code + Padding( + padding: const EdgeInsets.only(right: 16, top: 1, bottom: 1), + child: RichText( + text: TextSpan( + children: tokens + .map( + (t) => TextSpan( + text: t.text, + style: AppTypography.code.copyWith( + color: t.color, + height: 1.5, + ), + ), + ) + .toList(), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/session/domain/providers/session_timeline_provider.dart b/apps/mobile/lib/features/session/domain/providers/session_timeline_provider.dart new file mode 100644 index 0000000..c80d7dc --- /dev/null +++ b/apps/mobile/lib/features/session/domain/providers/session_timeline_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../../../core/providers/database_provider.dart'; + +/// Derives a [List] from the messages stored for [sessionId]. +/// +/// - User messages → [SessionEventType.userMessage] +/// - Agent text messages → [SessionEventType.agentMessage] +/// - Tool-call messages → [SessionEventType.toolUse] +/// +/// Results are sorted by [SessionEvent.timestamp] ascending. +final sessionEventsProvider = + Provider.family, String>((ref, sessionId) { + final messagesAsync = ref.watch(_rawMessagesProvider(sessionId)); + final messages = messagesAsync.valueOrNull ?? []; + + final events = []; + + for (final msg in messages) { + final eventType = switch (msg.messageType) { + 'tool_call' => SessionEventType.toolUse, + _ => msg.role == 'user' + ? SessionEventType.userMessage + : SessionEventType.agentMessage, + }; + + final title = switch (eventType) { + SessionEventType.userMessage => 'User', + SessionEventType.agentMessage => 'Agent', + SessionEventType.toolUse => 'Tool Use', + _ => 'Event', + }; + + final description = msg.content.isNotEmpty + ? (msg.content.length > 120 + ? '${msg.content.substring(0, 120)}…' + : msg.content) + : null; + + events.add(SessionEvent( + id: '${sessionId}_${msg.id}', + sessionId: sessionId, + eventType: eventType, + title: title, + description: description, + timestamp: msg.createdAt, + )); + } + + events.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + return events; +}); + +// Internal stream-backed provider for raw DB messages. +final _rawMessagesProvider = StreamProvider.family((ref, String sessionId) { + return ref.watch(databaseProvider).messageDao + .watchMessagesForSession(sessionId); +}); diff --git a/apps/mobile/lib/features/session/presentation/screens/session_detail_screen.dart b/apps/mobile/lib/features/session/presentation/screens/session_detail_screen.dart new file mode 100644 index 0000000..5b3db0f --- /dev/null +++ b/apps/mobile/lib/features/session/presentation/screens/session_detail_screen.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../domain/providers/session_timeline_provider.dart'; +import '../widgets/session_timeline.dart'; + +/// Displays the detail view for a single [ChatSession]. +/// +/// Shows a stats row (message count, tool-use count, duration) at the top, +/// followed by the session [SessionTimeline]. +class SessionDetailScreen extends ConsumerWidget { + final String sessionId; + final String? sessionTitle; + + const SessionDetailScreen({ + super.key, + required this.sessionId, + this.sessionTitle, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final events = ref.watch(sessionEventsProvider(sessionId)); + + final messageCount = events + .where((e) => + e.eventType == SessionEventType.userMessage || + e.eventType == SessionEventType.agentMessage) + .length; + + final toolUseCount = + events.where((e) => e.eventType == SessionEventType.toolUse).length; + + Duration? duration; + if (events.isNotEmpty) { + duration = events.last.timestamp.difference(events.first.timestamp); + } + + final title = (sessionTitle != null && sessionTitle!.isNotEmpty) + ? sessionTitle! + : 'Session'; + + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Column( + children: [ + _StatsRow( + messageCount: messageCount, + toolUseCount: toolUseCount, + duration: duration, + ), + const Divider(height: 1), + Expanded( + child: SessionTimeline(events: events), + ), + ], + ), + ); + } +} + +class _StatsRow extends StatelessWidget { + final int messageCount; + final int toolUseCount; + final Duration? duration; + + const _StatsRow({ + required this.messageCount, + required this.toolUseCount, + required this.duration, + }); + + String _formatDuration(Duration d) { + if (d.inHours > 0) { + return '${d.inHours}h ${d.inMinutes.remainder(60)}m'; + } + if (d.inMinutes > 0) return '${d.inMinutes}m'; + return '${d.inSeconds}s'; + } + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF1E1E1E), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatItem( + icon: Icons.chat_bubble_outline, + value: '$messageCount', + label: 'Messages', + ), + _StatItem( + icon: Icons.build_outlined, + value: '$toolUseCount', + label: 'Tool Uses', + ), + _StatItem( + icon: Icons.timer_outlined, + value: duration != null ? _formatDuration(duration!) : '—', + label: 'Duration', + ), + ], + ), + ); + } +} + +class _StatItem extends StatelessWidget { + final IconData icon; + final String value; + final String label; + + const _StatItem({ + required this.icon, + required this.value, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(icon, size: 18, color: const Color(0xFF9E9E9E)), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFFD4D4D4), + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9E9E9E), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/session/presentation/widgets/session_card.dart b/apps/mobile/lib/features/session/presentation/widgets/session_card.dart new file mode 100644 index 0000000..38e9e9b --- /dev/null +++ b/apps/mobile/lib/features/session/presentation/widgets/session_card.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../../../shared/utils/date_formatter.dart'; + +/// A list card summarising a [ChatSession]. +class SessionCard extends StatelessWidget { + final ChatSession session; + final VoidCallback onTap; + + const SessionCard({ + super.key, + required this.session, + required this.onTap, + }); + + Widget _agentChip(String agentType) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF252526), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF3E3E3E)), + ), + child: Text( + agentType, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + ), + ), + ); + } + + Widget _branchChip(String branch) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF569CD6).withOpacity(0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.alt_route, size: 10, color: Color(0xFF569CD6)), + const SizedBox(width: 3), + Text( + branch, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF569CD6), + ), + ), + ], + ), + ); + } + + Color _statusColor(SessionStatus status) { + return switch (status) { + SessionStatus.active => const Color(0xFF4CAF50), + SessionStatus.paused => const Color(0xFFFF9800), + SessionStatus.closed => const Color(0xFF9E9E9E), + }; + } + + @override + Widget build(BuildContext context) { + final title = session.title.isEmpty ? 'Session' : session.title; + final lastActivity = + session.lastMessageAt ?? session.updatedAt ?? session.createdAt; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // Leading: agent type chip + _agentChip(session.agentType), + const SizedBox(width: 12), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFFD4D4D4), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + DateFormatter.formatRelative(lastActivity), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + ), + ), + if (session.branch != null) ...[ + const SizedBox(width: 8), + _branchChip(session.branch!), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + // Trailing: status dot + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _statusColor(session.status), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/session/presentation/widgets/session_timeline.dart b/apps/mobile/lib/features/session/presentation/widgets/session_timeline.dart new file mode 100644 index 0000000..da9e50f --- /dev/null +++ b/apps/mobile/lib/features/session/presentation/widgets/session_timeline.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/session_models.dart'; +import 'timeline_tile.dart'; + +/// Renders the full list of [SessionEvent]s as a vertical timeline. +class SessionTimeline extends StatelessWidget { + final List events; + + const SessionTimeline({super.key, required this.events}); + + @override + Widget build(BuildContext context) { + if (events.isEmpty) { + return const Center( + child: Text( + 'No events yet', + style: TextStyle(color: Color(0xFF9E9E9E)), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + itemBuilder: (context, index) { + return TimelineTile( + event: events[index], + isFirst: index == 0, + isLast: index == events.length - 1, + ); + }, + ); + } +} diff --git a/apps/mobile/lib/features/session/presentation/widgets/timeline_tile.dart b/apps/mobile/lib/features/session/presentation/widgets/timeline_tile.dart new file mode 100644 index 0000000..d9bf94e --- /dev/null +++ b/apps/mobile/lib/features/session/presentation/widgets/timeline_tile.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/session_models.dart'; +import '../../../../shared/utils/date_formatter.dart'; + +/// A single row in the session timeline. +/// +/// Renders a vertical connector + dot on the left and an [_EventCard] on the +/// right. +class TimelineTile extends StatelessWidget { + final SessionEvent event; + final bool isFirst; + final bool isLast; + + const TimelineTile({ + super.key, + required this.event, + required this.isFirst, + required this.isLast, + }); + + Color _dotColor(SessionEventType type) { + return switch (type) { + SessionEventType.userMessage => const Color(0xFF569CD6), + SessionEventType.agentMessage => const Color(0xFF569CD6), + SessionEventType.toolUse => const Color(0xFFFF9800), + SessionEventType.toolResult => const Color(0xFF4EC9B0), + SessionEventType.sessionStart => const Color(0xFF4CAF50), + SessionEventType.sessionEnd => const Color(0xFF9E9E9E), + SessionEventType.hookEvent => const Color(0xFFCE9178), + }; + } + + @override + Widget build(BuildContext context) { + final dotColor = _dotColor(event.eventType); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left column: connector line + dot + SizedBox( + width: 24, + child: Column( + children: [ + // Top connector + Container( + width: 2, + height: isFirst ? 8 : 20, + color: isFirst + ? Colors.transparent + : const Color(0xFF3E3E3E), + ), + // Dot + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + // Bottom connector + Container( + width: 2, + height: isLast ? 8 : 30, + color: isLast + ? Colors.transparent + : const Color(0xFF3E3E3E), + ), + ], + ), + ), + const SizedBox(width: 12), + // Right: event card + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _EventCard(event: event), + ), + ), + ], + ), + ); + } +} + +class _EventCard extends StatelessWidget { + final SessionEvent event; + + const _EventCard({required this.event}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + event.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFFD4D4D4), + ), + ), + ), + Text( + DateFormatter.formatTime(event.timestamp), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + ), + ), + ], + ), + if (event.description != null) ...[ + const SizedBox(height: 4), + Text( + event.description!, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF9E9E9E), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/screens/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..f782965 --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/screens/settings_screen.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/network/connection_state.dart'; +import '../../../../core/providers/bridge_provider.dart'; +import '../../../../core/providers/theme_provider.dart'; +import '../../../../core/providers/token_storage_provider.dart'; +import '../../../../core/storage/secure_token_storage.dart'; +import '../widgets/setting_tile.dart'; + +final _themeModeProvider = StateProvider((ref) => ThemeMode.system); +final _notificationsEnabledProvider = StateProvider((ref) => true); + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(_themeModeProvider); + final notificationsEnabled = ref.watch(_notificationsEnabledProvider); + final highContrast = ref.watch(highContrastProvider); + final bridgeStatus = ref.watch(bridgeProvider); + final preferences = ref.watch(appPreferencesProvider); + final bridgeUrl = preferences.getBridgeUrl(); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + children: [ + const _SectionHeader(label: 'APPEARANCE'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SegmentedButton( + segments: const [ + ButtonSegment(value: ThemeMode.dark, label: Text('Dark')), + ButtonSegment(value: ThemeMode.light, label: Text('Light')), + ButtonSegment(value: ThemeMode.system, label: Text('System')), + ], + selected: {themeMode}, + onSelectionChanged: (selection) { + ref.read(_themeModeProvider.notifier).state = selection.first; + }, + ), + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('High contrast'), + subtitle: const Text('Increases contrast for better visibility'), + value: highContrast, + onChanged: (value) { + ref.read(highContrastProvider.notifier).setHighContrast(value); + }, + ), + const Divider(height: 1), + const _SectionHeader(label: 'NOTIFICATIONS'), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('Enable notifications'), + value: notificationsEnabled, + onChanged: (value) { + ref.read(_notificationsEnabledProvider.notifier).state = value; + }, + ), + const Divider(height: 1), + const _SectionHeader(label: 'BRIDGE'), + SettingTile( + leading: const Icon(Icons.link, size: 20), + title: const Text('Saved bridge'), + subtitle: Text( + bridgeUrl == null || bridgeUrl.isEmpty + ? 'No bridge paired yet' + : bridgeUrl, + style: const TextStyle( + color: Color(0xFF9E9E9E), + fontSize: 12, + ), + ), + ), + SettingTile( + leading: const Icon(Icons.route, size: 20), + title: const Text('Run bridge setup'), + subtitle: const Text( + 'Update the saved bridge URL or pairing token', + style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E)), + ), + onTap: () => context.go('/bridge-setup'), + ), + SettingTile( + leading: Icon( + bridgeStatus == ConnectionStatus.connected + ? Icons.link_off + : Icons.link, + size: 20, + color: bridgeStatus == ConnectionStatus.connected + ? const Color(0xFFF44747) + : const Color(0xFF9E9E9E), + ), + title: Text( + bridgeStatus == ConnectionStatus.connected + ? 'Disconnect' + : 'Bridge offline', + style: TextStyle( + color: bridgeStatus == ConnectionStatus.connected + ? const Color(0xFFF44747) + : const Color(0xFF9E9E9E), + ), + ), + subtitle: Text( + switch (bridgeStatus) { + ConnectionStatus.connected => 'Connected to the paired bridge', + ConnectionStatus.connecting => 'Connecting…', + ConnectionStatus.reconnecting => 'Reconnecting…', + ConnectionStatus.error => 'Last bridge connection failed', + ConnectionStatus.disconnected => 'No active bridge connection', + }, + style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E)), + ), + onTap: bridgeStatus == ConnectionStatus.connected + ? () { + ref.read(bridgeProvider.notifier).disconnect(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Disconnected from bridge')), + ); + } + : null, + ), + SettingTile( + leading: const Icon( + Icons.delete_outline, + size: 20, + color: Color(0xFFF44747), + ), + title: const Text( + 'Forget saved pairing', + style: TextStyle(color: Color(0xFFF44747)), + ), + subtitle: const Text( + 'Clears the saved bridge URL and pairing token', + style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E)), + ), + showDivider: false, + onTap: () { + unawaited(_forgetSavedPairing(context, ref)); + }, + ), + const Divider(height: 1), + const _SectionHeader(label: 'ABOUT'), + const SettingTile( + leading: Icon(Icons.info_outline, size: 20), + title: Text('Version'), + trailing: Text( + '0.1.0', + style: TextStyle( + fontSize: 13, + color: Color(0xFF9E9E9E), + fontFamily: 'JetBrainsMono', + ), + ), + ), + SettingTile( + leading: const Icon(Icons.description_outlined, size: 20), + title: const Text('Licenses'), + onTap: () => showLicensePage( + context: context, + applicationName: 'ReCursor', + applicationVersion: '0.1.0', + ), + ), + SettingTile( + leading: const Icon(Icons.open_in_new, size: 20), + title: const Text('GitHub'), + showDivider: false, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('https://github.com/RecursiveDev/ReCursor'), + ), + ); + }, + ), + const SizedBox(height: 24), + ], + ), + ); + } + + Future _forgetSavedPairing(BuildContext context, WidgetRef ref) async { + ref.read(bridgeProvider.notifier).disconnect(); + await ref.read(appPreferencesProvider).setBridgeUrl(null); + await ref.read(tokenStorageProvider).deleteToken(kBridgeToken); + + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cleared saved bridge pairing')), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + color: Color(0xFF9E9E9E), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/settings/presentation/widgets/setting_tile.dart b/apps/mobile/lib/features/settings/presentation/widgets/setting_tile.dart new file mode 100644 index 0000000..0c73d95 --- /dev/null +++ b/apps/mobile/lib/features/settings/presentation/widgets/setting_tile.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// A thin wrapper around [ListTile] with consistent padding and an optional +/// bottom divider for use in settings screens. +class SettingTile extends StatelessWidget { + final Widget? leading; + final Widget title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool showDivider; + + const SettingTile({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: onTap, + ), + if (showDivider) + const Divider(height: 1, indent: 16, endIndent: 16), + ], + ); + } +} diff --git a/apps/mobile/lib/features/startup/domain/bridge_startup_controller.dart b/apps/mobile/lib/features/startup/domain/bridge_startup_controller.dart new file mode 100644 index 0000000..da99a40 --- /dev/null +++ b/apps/mobile/lib/features/startup/domain/bridge_startup_controller.dart @@ -0,0 +1,91 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/network/bridge_connection_validator.dart'; +import '../../../../core/network/connection_state.dart'; +import '../../../../core/network/websocket_service.dart'; +import '../../../../core/providers/preferences_provider.dart'; +import '../../../../core/providers/token_storage_provider.dart'; +import '../../../../core/providers/websocket_provider.dart'; +import '../../../../core/storage/preferences.dart'; +import '../../../../core/storage/secure_token_storage.dart'; + +final bridgeStartupControllerProvider = + Provider((ref) { + return BridgeStartupController( + preferences: ref.read(appPreferencesProvider), + tokenStorage: ref.read(tokenStorageProvider), + webSocketService: ref.read(webSocketServiceProvider), + ); +}); + +final bridgeStartupErrorProvider = StateProvider((ref) => null); + +enum AppStartupDestination { bridgeSetup, home } + +class AppStartupResult { + const AppStartupResult._({required this.destination, this.message}); + + const AppStartupResult.bridgeSetup({String? message}) + : this._( + destination: AppStartupDestination.bridgeSetup, + message: message, + ); + + const AppStartupResult.home() + : this._(destination: AppStartupDestination.home); + + final AppStartupDestination destination; + final String? message; +} + +class BridgeStartupController { + BridgeStartupController({ + required AppPreferences preferences, + required SecureTokenStorage tokenStorage, + required WebSocketService webSocketService, + }) : _preferences = preferences, + _tokenStorage = tokenStorage, + _webSocketService = webSocketService; + + final AppPreferences _preferences; + final SecureTokenStorage _tokenStorage; + final WebSocketService _webSocketService; + + Future restore() async { + final savedUrl = _preferences.getBridgeUrl()?.trim(); + final savedToken = await _tokenStorage.getToken(kBridgeToken); + final normalizedToken = savedToken?.trim(); + + if (savedUrl == null || + savedUrl.isEmpty || + normalizedToken == null || + normalizedToken.isEmpty) { + return const AppStartupResult.bridgeSetup(); + } + + // Validate saved credentials before attempting connection + final validation = BridgeConnectionValidator.validate( + url: savedUrl, + token: normalizedToken, + ); + if (!validation.isValid) { + return AppStartupResult.bridgeSetup( + message: + 'Invalid saved bridge configuration: ${validation.errorMessage}', + ); + } + + if (_webSocketService.currentStatus == ConnectionStatus.connected) { + return const AppStartupResult.home(); + } + + try { + await _webSocketService.connect(url: savedUrl, token: normalizedToken); + return const AppStartupResult.home(); + } catch (error) { + return AppStartupResult.bridgeSetup( + message: 'Unable to reconnect to the saved bridge. $error', + ); + } + } +} diff --git a/apps/mobile/lib/features/startup/presentation/screens/bridge_setup_screen.dart b/apps/mobile/lib/features/startup/presentation/screens/bridge_setup_screen.dart new file mode 100644 index 0000000..5cf1172 --- /dev/null +++ b/apps/mobile/lib/features/startup/presentation/screens/bridge_setup_screen.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../../../core/network/bridge_connection_validator.dart'; +import '../../../../core/providers/bridge_provider.dart'; +import '../../../../core/providers/preferences_provider.dart'; +import '../../../../core/providers/token_storage_provider.dart'; +import '../../../../core/storage/secure_token_storage.dart'; +import '../../domain/bridge_startup_controller.dart'; + +class BridgeSetupScreen extends ConsumerStatefulWidget { + const BridgeSetupScreen({super.key}); + + @override + ConsumerState createState() => _BridgeSetupScreenState(); +} + +class _BridgeSetupScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + final TextEditingController _urlController = TextEditingController(); + final TextEditingController _tokenController = TextEditingController(); + bool _connecting = false; + String? _error; + bool _scanned = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this, initialIndex: 1); + unawaited(_loadSavedBridgeConfig()); + } + + Future _loadSavedBridgeConfig() async { + final preferences = ref.read(appPreferencesProvider); + final storage = ref.read(tokenStorageProvider); + final savedUrl = preferences.getBridgeUrl(); + final savedToken = await storage.getToken(kBridgeToken); + final startupError = ref.read(bridgeStartupErrorProvider); + + if (!mounted) { + return; + } + + setState(() { + if (savedUrl != null && savedUrl.isNotEmpty) { + _urlController.text = savedUrl; + } + if (savedToken != null && savedToken.isNotEmpty) { + _tokenController.text = savedToken; + } + if (startupError != null && startupError.isNotEmpty) { + _error = startupError; + } + }); + + ref.read(bridgeStartupErrorProvider.notifier).state = null; + } + + @override + void dispose() { + _tabController.dispose(); + _urlController.dispose(); + _tokenController.dispose(); + super.dispose(); + } + + void _onQrDetect(BarcodeCapture capture) { + if (_scanned) { + return; + } + + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw == null) { + return; + } + + final uri = Uri.tryParse(raw); + if (uri == null) { + _setError('QR code did not contain a valid bridge pairing URI.'); + return; + } + + final url = uri.queryParameters['url'] ?? ''; + final token = uri.queryParameters['token'] ?? ''; + final validation = BridgeConnectionValidator.validate( + url: url, + token: token, + ); + + if (!validation.isValid) { + _setError(validation.errorMessage!); + return; + } + + setState(() { + _scanned = true; + _error = null; + _urlController.text = url; + _tokenController.text = token; + }); + _tabController.animateTo(1); + } + + void _setError(String message) { + setState(() { + _error = message; + }); + } + + Future _connect() async { + final url = _urlController.text.trim(); + final token = _tokenController.text.trim(); + final validation = BridgeConnectionValidator.validate( + url: url, + token: token, + ); + + if (!validation.isValid) { + _setError(validation.errorMessage!); + return; + } + + setState(() { + _connecting = true; + _error = null; + }); + + try { + await ref.read(bridgeProvider.notifier).connect(url, token); + await ref.read(appPreferencesProvider).setBridgeUrl(url); + await ref.read(tokenStorageProvider).saveToken(kBridgeToken, token); + ref.read(bridgeStartupErrorProvider.notifier).state = null; + if (mounted) { + context.go('/home/chat'); + } + } catch (error) { + _setError(error.toString()); + } finally { + if (mounted) { + setState(() { + _connecting = false; + }); + } + } + } + + Widget _buildTabBody() { + return AnimatedBuilder( + animation: _tabController.animation ?? _tabController, + builder: (context, _) { + final currentIndex = _tabController.index; + if (currentIndex == 0) { + return _QrTab(onDetect: _onQrDetect); + } + return _ManualTab( + urlController: _urlController, + tokenController: _tokenController, + connecting: _connecting, + error: _error, + onConnect: _connect, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('bridgeSetupScreen'), + backgroundColor: const Color(0xFF1E1E1E), + appBar: AppBar( + backgroundColor: const Color(0xFF252526), + title: const Text('Bridge Setup'), + bottom: TabBar( + controller: _tabController, + indicatorColor: const Color(0xFF569CD6), + labelColor: const Color(0xFF569CD6), + unselectedLabelColor: Colors.grey, + tabs: const [ + Tab(icon: Icon(Icons.qr_code_scanner), text: 'QR Scanner'), + Tab(icon: Icon(Icons.edit), text: 'Manual Entry'), + ], + ), + ), + body: _buildTabBody(), + ); + } +} + +class _QrTab extends StatelessWidget { + const _QrTab({required this.onDetect}); + + final void Function(BarcodeCapture) onDetect; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: MobileScanner(onDetect: onDetect), + ), + ), + const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Scan a QR code that contains your private wss:// bridge URL and ' + 'bridge pairing token.', + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFF9CDCFE)), + ), + ), + ], + ); + } +} + +class _ManualTab extends StatelessWidget { + const _ManualTab({ + required this.urlController, + required this.tokenController, + required this.connecting, + required this.error, + required this.onConnect, + }); + + final TextEditingController urlController; + final TextEditingController tokenController; + final bool connecting; + final String? error; + final VoidCallback onConnect; + + @override + Widget build(BuildContext context) { + const inputDecoration = InputDecoration( + filled: true, + fillColor: Color(0xFF252526), + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide.none, + ), + labelStyle: TextStyle(color: Color(0xFF9CDCFE)), + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Start by pairing with your local bridge. No account sign-in is ' + 'required.', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + const Text( + 'Use the bridge\'s private Tailscale/WireGuard address. Public ' + 'internet bridge endpoints are outside the docs contract.', + style: TextStyle(color: Color(0xFF9CDCFE)), + ), + const SizedBox(height: 16), + TextField( + controller: urlController, + style: const TextStyle(color: Colors.white), + decoration: inputDecoration.copyWith( + labelText: 'Bridge URL (wss://...)', + hintText: 'wss://your-bridge.tailnet.ts.net:3000', + hintStyle: const TextStyle(color: Colors.grey), + ), + ), + const SizedBox(height: 16), + TextField( + controller: tokenController, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: inputDecoration.copyWith( + labelText: 'Bridge Pairing Token', + helperText: 'Stored securely on-device after a successful pair.', + helperStyle: const TextStyle(color: Colors.grey), + ), + ), + const SizedBox(height: 24), + if (error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + error!, + style: const TextStyle(color: Colors.redAccent), + textAlign: TextAlign.center, + ), + ), + FilledButton( + onPressed: connecting ? null : onConnect, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF569CD6), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: connecting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Connect', style: TextStyle(fontSize: 16)), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/startup/presentation/screens/splash_screen.dart b/apps/mobile/lib/features/startup/presentation/screens/splash_screen.dart new file mode 100644 index 0000000..c7ebdc8 --- /dev/null +++ b/apps/mobile/lib/features/startup/presentation/screens/splash_screen.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/bridge_startup_controller.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn); + _controller.forward(); + unawaited(_restoreStartup()); + } + + Future _restoreStartup() async { + await Future.delayed(const Duration(milliseconds: 1200)); + final result = await ref.read(bridgeStartupControllerProvider).restore(); + ref.read(bridgeStartupErrorProvider.notifier).state = result.message; + + if (!mounted) { + return; + } + + switch (result.destination) { + case AppStartupDestination.bridgeSetup: + context.go('/bridge-setup'); + return; + case AppStartupDestination.home: + context.go('/home/chat'); + return; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('splashScreen'), + backgroundColor: const Color(0xFF1E1E1E), + body: Center( + child: FadeTransition( + opacity: _opacity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + 'assets/branding/recursor_logo_dark.svg', + width: 200, + fit: BoxFit.contain, + ), + const SizedBox(height: 24), + const Text( + 'Restore Bridge Session', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + const Text( + 'Checking for a saved bridge pairing…', + style: TextStyle(color: Color(0xFF9CDCFE)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/terminal/domain/providers/terminal_provider.dart b/apps/mobile/lib/features/terminal/domain/providers/terminal_provider.dart new file mode 100644 index 0000000..296f6ac --- /dev/null +++ b/apps/mobile/lib/features/terminal/domain/providers/terminal_provider.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/network/websocket_messages.dart'; +import '../../../../core/providers/websocket_provider.dart'; + +// --------------------------------------------------------------------------- +// Terminal output per session +// --------------------------------------------------------------------------- + +class TerminalNotifier extends StateNotifier> { + TerminalNotifier(this._ref, this._sessionId) : super([]) { + _listen(); + } + + final Ref _ref; + final String _sessionId; + StreamSubscription? _sub; + + void _listen() { + final service = _ref.read(webSocketServiceProvider); + _sub = service.messages.listen(_handleMessage); + } + + void _handleMessage(BridgeMessage msg) { + if (msg.type == BridgeMessageType.claudeEvent) { + final msgType = msg.payload['type'] as String?; + final sessionId = msg.payload['session_id'] as String?; + + if (msgType == 'terminal_output' && sessionId == _sessionId) { + final line = msg.payload['data'] as String? ?? ''; + state = [...state, line]; + } + } + } + + /// Sends a `terminal_create` WS message. + void createSession(String sessionId, String workingDir) { + final service = _ref.read(webSocketServiceProvider); + service.send(BridgeMessage( + type: BridgeMessageType.claudeEvent, + timestamp: DateTime.now().toUtc(), + payload: { + 'type': 'terminal_create', + 'session_id': sessionId, + 'working_directory': workingDir, + }, + )); + } + + /// Sends a `terminal_input` WS message and echoes the command locally. + void sendInput(String sessionId, String command) { + final service = _ref.read(webSocketServiceProvider); + service.send(BridgeMessage( + type: BridgeMessageType.claudeEvent, + timestamp: DateTime.now().toUtc(), + payload: { + 'type': 'terminal_input', + 'session_id': sessionId, + 'data': '$command\n', + }, + )); + // Echo locally so the user sees their own input immediately. + state = [...state, '\$ $command']; + } + + /// Sends a `terminal_close` WS message. + void closeSession(String sessionId) { + final service = _ref.read(webSocketServiceProvider); + service.send(BridgeMessage( + type: BridgeMessageType.claudeEvent, + timestamp: DateTime.now().toUtc(), + payload: { + 'type': 'terminal_close', + 'session_id': sessionId, + }, + )); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } +} + +final terminalOutputProvider = + StateNotifierProvider.family, String>( + (ref, sessionId) => TerminalNotifier(ref, sessionId), +); diff --git a/apps/mobile/lib/features/terminal/presentation/screens/terminal_screen.dart b/apps/mobile/lib/features/terminal/presentation/screens/terminal_screen.dart new file mode 100644 index 0000000..9681053 --- /dev/null +++ b/apps/mobile/lib/features/terminal/presentation/screens/terminal_screen.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/providers/terminal_provider.dart'; +import '../widgets/terminal_output.dart'; + +/// Terminal emulator screen backed by a WebSocket bridge session. +class TerminalScreen extends ConsumerStatefulWidget { + final String sessionId; + final String workingDirectory; + + const TerminalScreen({ + super.key, + required this.sessionId, + this.workingDirectory = '~', + }); + + @override + ConsumerState createState() => _TerminalScreenState(); +} + +class _TerminalScreenState extends ConsumerState { + final _inputController = TextEditingController(); + final _scrollController = ScrollController(); + final _historyScrollController = ScrollController(); + final _focusNode = FocusNode(); + + final List _commandHistory = []; + int _historyIndex = -1; + static const int _maxHistory = 50; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(terminalOutputProvider(widget.sessionId).notifier) + .createSession(widget.sessionId, widget.workingDirectory); + }); + } + + @override + void dispose() { + _inputController.dispose(); + _scrollController.dispose(); + _historyScrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _sendCommand() { + final command = _inputController.text.trim(); + if (command.isEmpty) return; + _inputController.clear(); + _historyIndex = -1; + + // Add to history (avoid consecutive duplicates) + if (_commandHistory.isEmpty || _commandHistory.last != command) { + setState(() { + _commandHistory.add(command); + if (_commandHistory.length > _maxHistory) { + _commandHistory.removeAt(0); + } + }); + } + + ref + .read(terminalOutputProvider(widget.sessionId).notifier) + .sendInput(widget.sessionId, command); + + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ); + } + } + + void _recallHistory(int direction) { + // direction: -1 = up (older), +1 = down (newer) + if (_commandHistory.isEmpty) return; + setState(() { + _historyIndex = (_historyIndex - direction).clamp( + -1, + _commandHistory.length - 1, + ); + if (_historyIndex == -1) { + _inputController.clear(); + } else { + final idx = _commandHistory.length - 1 - _historyIndex; + _inputController.text = _commandHistory[idx]; + _inputController.selection = TextSelection.fromPosition( + TextPosition(offset: _inputController.text.length), + ); + } + }); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _recallHistory(-1); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _recallHistory(1); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + Widget _buildTerminalOutput() { + final lines = ref.watch(terminalOutputProvider(widget.sessionId)); + return TerminalOutput(lines: lines); + } + + Widget _buildInputBar() { + return _InputBar( + controller: _inputController, + focusNode: _focusNode, + onSend: _sendCommand, + onKeyEvent: _handleKeyEvent, + ); + } + + Widget _buildHistoryList() { + return ListView.builder( + controller: _historyScrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _commandHistory.length, + itemBuilder: (context, index) { + // Show most recent at top + final cmd = _commandHistory[_commandHistory.length - 1 - index]; + return InkWell( + onTap: () { + _inputController.text = cmd; + _inputController.selection = TextSelection.fromPosition( + TextPosition(offset: cmd.length), + ); + _focusNode.requestFocus(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Text( + cmd, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 12, + color: Color(0xFFD4D4D4), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + // Auto-scroll when new lines arrive. + ref.listen(terminalOutputProvider(widget.sessionId), (_, __) { + WidgetsBinding.instance + .addPostFrameCallback((_) => _scrollToBottom()); + }); + + return Scaffold( + backgroundColor: const Color(0xFF0D1117), + appBar: AppBar( + backgroundColor: const Color(0xFF161B22), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Terminal', + style: TextStyle(fontSize: 15, color: Color(0xFFD4D4D4))), + Text( + widget.workingDirectory, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + fontFamily: 'JetBrainsMono', + ), + ), + ], + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + final isLandscape = constraints.maxWidth > 600; + + if (isLandscape) { + return Row( + children: [ + // Left 60%: terminal output + Flexible( + flex: 60, + child: Column( + children: [ + Expanded(child: _buildTerminalOutput()), + ], + ), + ), + const VerticalDivider( + width: 1, + color: Color(0xFF30363D), + ), + // Right 40%: command history + input at bottom + Flexible( + flex: 40, + child: Container( + color: const Color(0xFF0D1117), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: const Color(0xFF161B22), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + width: double.infinity, + child: const Text( + 'History', + style: TextStyle( + fontSize: 11, + color: Color(0xFF9E9E9E), + fontFamily: 'JetBrainsMono', + ), + ), + ), + Expanded(child: _buildHistoryList()), + const Divider(height: 1, color: Color(0xFF30363D)), + _buildInputBar(), + ], + ), + ), + ), + ], + ); + } + + // Portrait layout + return Column( + children: [ + Expanded(child: _buildTerminalOutput()), + _buildInputBar(), + ], + ); + }, + ), + ); + } +} + +class _InputBar extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback onSend; + final KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent; + + const _InputBar({ + required this.controller, + required this.focusNode, + required this.onSend, + required this.onKeyEvent, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF161B22), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + const Text( + '\$', + style: TextStyle( + color: Color(0xFF4EC9B0), + fontFamily: 'JetBrainsMono', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: TextField( + controller: controller, + style: const TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFFD4D4D4), + ), + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: 'Enter command…', + hintStyle: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 13, + color: Color(0xFF555555), + ), + filled: false, + ), + onSubmitted: (_) => onSend(), + autocorrect: false, + enableSuggestions: false, + ), + ), + ), + IconButton( + onPressed: onSend, + icon: const Icon(Icons.send, size: 18), + color: const Color(0xFF569CD6), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/terminal/presentation/widgets/ansi_renderer.dart b/apps/mobile/lib/features/terminal/presentation/widgets/ansi_renderer.dart new file mode 100644 index 0000000..5feb294 --- /dev/null +++ b/apps/mobile/lib/features/terminal/presentation/widgets/ansi_renderer.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/utils/ansi_parser.dart'; + +/// Converts a terminal output line (potentially containing ANSI escape codes) +/// into a [InlineSpan] suitable for a [RichText] widget. +class AnsiRenderer { + AnsiRenderer._(); + + static InlineSpan render(String line) { + final spans = AnsiParser.parse(line); + + if (spans.isEmpty) { + return const TextSpan(text: ''); + } + + final children = spans.map((span) { + return TextSpan( + text: span.text, + style: TextStyle( + color: span.color ?? const Color(0xFFD4D4D4), + fontWeight: span.bold ? FontWeight.bold : FontWeight.normal, + fontStyle: span.italic ? FontStyle.italic : FontStyle.normal, + fontFamily: 'JetBrainsMono', + fontSize: 13, + ), + ); + }).toList(); + + return TextSpan(children: children); + } +} diff --git a/apps/mobile/lib/features/terminal/presentation/widgets/terminal_output.dart b/apps/mobile/lib/features/terminal/presentation/widgets/terminal_output.dart new file mode 100644 index 0000000..84344ed --- /dev/null +++ b/apps/mobile/lib/features/terminal/presentation/widgets/terminal_output.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'ansi_renderer.dart'; + +/// Renders a list of terminal output lines with ANSI colour support. +class TerminalOutput extends StatelessWidget { + final List lines; + + const TerminalOutput({super.key, required this.lines}); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFF0D1117), + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + itemCount: lines.length, + itemBuilder: (context, index) { + return RichText( + text: AnsiRenderer.render(lines[index]), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart new file mode 100644 index 0000000..ea54bfe --- /dev/null +++ b/apps/mobile/lib/main.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'app.dart'; +import 'core/monitoring/sentry_service.dart'; +import 'core/providers/theme_provider.dart'; +import 'core/storage/preferences.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Hive.initFlutter(); + + final prefs = AppPreferences(); + await prefs.init(); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + SentryService.captureException(details.exception, stackTrace: details.stack); + }; + + await SentryService.init(() => runApp( + ProviderScope( + overrides: [ + appPreferencesProvider.overrideWithValue(prefs), + ], + child: const ReCursorApp(), + ), + )); +} diff --git a/apps/mobile/lib/shared/constants/colors.dart b/apps/mobile/lib/shared/constants/colors.dart new file mode 100644 index 0000000..9b18f4c --- /dev/null +++ b/apps/mobile/lib/shared/constants/colors.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../core/config/theme.dart'; + +// Re-export AppColors constants +const kBackground = AppColors.background; +const kSurface = AppColors.surface; +const kSurfaceVariant = AppColors.surfaceVariant; +const kPrimary = AppColors.primary; +const kSecondary = AppColors.secondary; +const kError = AppColors.error; +const kAdded = AppColors.added; +const kRemoved = AppColors.removed; +const kAccent = AppColors.accent; +const kTextPrimary = AppColors.textPrimary; +const kTextSecondary = AppColors.textSecondary; +const kBorder = AppColors.border; + +// Diff line background colors +const kDiffAdded = Color(0x334EC9B0); +const kDiffRemoved = Color(0x33F44747); +const kDiffContext = Color(0x00000000); // transparent + +// Risk level colors +const kRiskLow = Color(0xFF4CAF50); +const kRiskMedium = Color(0xFFFF9800); +const kRiskHigh = Color(0xFFF44747); +const kRiskCritical = Color(0xFF8B0000); + +// Tool status colors +const kToolPending = Color(0xFF569CD6); +const kToolRunning = Color(0xFFFF9800); +const kToolCompleted = Color(0xFF4EC9B0); +const kToolError = Color(0xFFF44747); diff --git a/apps/mobile/lib/shared/constants/dimens.dart b/apps/mobile/lib/shared/constants/dimens.dart new file mode 100644 index 0000000..20a53de --- /dev/null +++ b/apps/mobile/lib/shared/constants/dimens.dart @@ -0,0 +1,22 @@ +class Dimens { + static const double paddingXS = 4; + static const double paddingS = 8; + static const double paddingM = 16; + static const double paddingL = 24; + static const double paddingXL = 32; + + static const double radiusS = 4; + static const double radiusM = 8; + static const double radiusL = 16; + + static const double iconS = 16; + static const double iconM = 24; + static const double iconL = 32; + + static const double cardElevation = 2; + static const double appBarHeight = 56; + static const double bottomNavHeight = 60; + static const double inputHeight = 48; + + static const double tabletBreakpoint = 600; +} diff --git a/apps/mobile/lib/shared/constants/typography.dart b/apps/mobile/lib/shared/constants/typography.dart new file mode 100644 index 0000000..d99e3f6 --- /dev/null +++ b/apps/mobile/lib/shared/constants/typography.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class AppTypography { + static const String monoFont = 'JetBrainsMono'; + + static const TextStyle code = TextStyle( + fontFamily: monoFont, + fontSize: 13, + color: Color(0xFFD4D4D4), + ); + + static const TextStyle codeSmall = TextStyle( + fontFamily: monoFont, + fontSize: 11, + color: Color(0xFF9E9E9E), + ); + + static const TextStyle label = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ); + + static const TextStyle sectionHeader = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.0, + ); +} diff --git a/apps/mobile/lib/shared/utils/ansi_parser.dart b/apps/mobile/lib/shared/utils/ansi_parser.dart new file mode 100644 index 0000000..47bc890 --- /dev/null +++ b/apps/mobile/lib/shared/utils/ansi_parser.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +class AnsiSpan { + final String text; + final Color? color; + final bool bold; + final bool italic; + + const AnsiSpan( + this.text, { + this.color, + this.bold = false, + this.italic = false, + }); +} + +/// Parses ANSI escape codes from terminal output. +class AnsiParser { + static final _ansiEscapePattern = RegExp(r'\x1B\[[0-9;]*m'); + + // Standard 8/16 ANSI foreground color table (codes 30–37, bright 90–97) + static const _fgColors = { + 30: Color(0xFF000000), // black + 31: Color(0xFFCD3131), // red + 32: Color(0xFF0DBC79), // green + 33: Color(0xFFE5E510), // yellow + 34: Color(0xFF2472C8), // blue + 35: Color(0xFFBC3FBC), // magenta + 36: Color(0xFF11A8CD), // cyan + 37: Color(0xFFE5E5E5), // white + 90: Color(0xFF666666), // bright black (gray) + 91: Color(0xFFF14C4C), // bright red + 92: Color(0xFF23D18B), // bright green + 93: Color(0xFFF5F543), // bright yellow + 94: Color(0xFF3B8EEA), // bright blue + 95: Color(0xFFD670D6), // bright magenta + 96: Color(0xFF29B8DB), // bright cyan + 97: Color(0xFFFFFFFF), // bright white + }; + + /// Remove all ANSI escape sequences from [text]. + static String stripAnsi(String text) { + return text.replaceAll(_ansiEscapePattern, ''); + } + + /// Parse [text] containing ANSI escape sequences into a list of [AnsiSpan]s. + static List parse(String text) { + final spans = []; + + Color? currentColor; + bool bold = false; + bool italic = false; + + int cursor = 0; + + final matches = _ansiEscapePattern.allMatches(text).toList(); + + for (final match in matches) { + // Append plain text before this escape sequence + if (match.start > cursor) { + final plain = text.substring(cursor, match.start); + if (plain.isNotEmpty) { + spans.add(AnsiSpan(plain, color: currentColor, bold: bold, italic: italic)); + } + } + + // Parse the escape sequence codes + final sequence = match.group(0)!; + final inner = sequence.substring(2, sequence.length - 1); // strip ESC[ and m + final codes = inner.isEmpty + ? [0] + : inner.split(';').map((s) => int.tryParse(s) ?? 0).toList(); + + for (final code in codes) { + if (code == 0) { + // Reset + currentColor = null; + bold = false; + italic = false; + } else if (code == 1) { + bold = true; + } else if (code == 3) { + italic = true; + } else if (code == 22) { + bold = false; + } else if (code == 23) { + italic = false; + } else if (_fgColors.containsKey(code)) { + currentColor = _fgColors[code]; + } + // Background colors (40–47) are parsed but ignored for spans + } + + cursor = match.end; + } + + // Remaining text after the last escape sequence + if (cursor < text.length) { + final remaining = text.substring(cursor); + if (remaining.isNotEmpty) { + spans.add(AnsiSpan(remaining, color: currentColor, bold: bold, italic: italic)); + } + } + + // If no escape sequences were found, return the whole string as one span + if (spans.isEmpty && text.isNotEmpty) { + spans.add(AnsiSpan(text)); + } + + return spans; + } +} diff --git a/apps/mobile/lib/shared/utils/date_formatter.dart b/apps/mobile/lib/shared/utils/date_formatter.dart new file mode 100644 index 0000000..2cca00b --- /dev/null +++ b/apps/mobile/lib/shared/utils/date_formatter.dart @@ -0,0 +1,55 @@ +class DateFormatter { + static const _months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + /// Returns a human-readable relative time string. + /// Examples: "just now", "2m ago", "1h ago", "yesterday", "Mar 15" + static String formatRelative(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inSeconds < 60) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + + final today = DateTime(now.year, now.month, now.day); + final dtDay = DateTime(dt.year, dt.month, dt.day); + final dayDiff = today.difference(dtDay).inDays; + + if (dayDiff == 1) return 'yesterday'; + + return '${_months[dt.month - 1]} ${dt.day}'; + } + + /// Returns a full date-time string. + /// Example: "Mar 15, 2026 10:32 AM" + static String formatFull(DateTime dt) { + final month = _months[dt.month - 1]; + final time = formatTime(dt); + return '$month ${dt.day}, ${dt.year} $time'; + } + + /// Returns a time-only string. + /// Example: "10:32 AM" + static String formatTime(DateTime dt) { + final hour = dt.hour % 12 == 0 ? 12 : dt.hour % 12; + final minute = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + return '$hour:$minute $period'; + } + + /// Returns a human-readable duration string. + /// Examples: "1h 23m", "45s" + static String formatDuration(Duration d) { + if (d.inHours > 0) { + final minutes = d.inMinutes.remainder(60); + return '${d.inHours}h ${minutes}m'; + } + if (d.inMinutes > 0) { + return '${d.inMinutes}m'; + } + return '${d.inSeconds}s'; + } +} diff --git a/apps/mobile/lib/shared/utils/diff_parser.dart b/apps/mobile/lib/shared/utils/diff_parser.dart new file mode 100644 index 0000000..7293732 --- /dev/null +++ b/apps/mobile/lib/shared/utils/diff_parser.dart @@ -0,0 +1,158 @@ +import '../../core/models/git_models.dart'; + +/// Parses unified diff strings into [DiffFile] objects. +class DiffParser { + static final _fileHeaderOld = RegExp(r'^--- a/(.+)$'); + static final _fileHeaderNew = RegExp(r'^\+\+\+ b/(.+)$'); + static final _hunkHeader = RegExp( + r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$', + ); + + /// Parse a unified diff string into a list of [DiffFile] objects. + static List parse(String rawDiff) { + final files = []; + final lines = rawDiff.split('\n'); + + String? oldPath; + String? newPath; + final hunks = []; + + List? currentHunkLines; + DiffHunk? currentHunk; + int oldLineNum = 0; + int newLineNum = 0; + + void flushHunk() { + if (currentHunk != null && currentHunkLines != null) { + hunks.add(DiffHunk( + header: currentHunk!.header, + oldStart: currentHunk!.oldStart, + oldLines: currentHunk!.oldLines, + newStart: currentHunk!.newStart, + newLines: currentHunk!.newLines, + lines: List.unmodifiable(currentHunkLines!), + )); + currentHunk = null; + currentHunkLines = null; + } + } + + void flushFile() { + if (oldPath != null && newPath != null) { + flushHunk(); + + int additions = 0; + int deletions = 0; + for (final hunk in hunks) { + for (final line in hunk.lines) { + if (line.type == DiffLineType.added) additions++; + if (line.type == DiffLineType.removed) deletions++; + } + } + + final path = newPath ?? oldPath ?? ''; + files.add(DiffFile( + path: path, + oldPath: oldPath!, + newPath: newPath!, + status: _inferStatus(oldPath!, newPath!), + additions: additions, + deletions: deletions, + hunks: List.unmodifiable(hunks), + )); + + oldPath = null; + newPath = null; + hunks.clear(); + } + } + + for (final line in lines) { + // New file header signals start of a new file diff + if (line.startsWith('--- ')) { + final match = _fileHeaderOld.firstMatch(line); + if (match != null) { + flushFile(); + oldPath = match.group(1); + continue; + } + // Handle /dev/null for new files + if (line == '--- /dev/null') { + flushFile(); + oldPath = '/dev/null'; + continue; + } + } + + if (line.startsWith('+++ ')) { + final match = _fileHeaderNew.firstMatch(line); + if (match != null) { + newPath = match.group(1); + continue; + } + if (line == '+++ /dev/null') { + newPath = '/dev/null'; + continue; + } + } + + // Hunk header + final hunkMatch = _hunkHeader.firstMatch(line); + if (hunkMatch != null) { + flushHunk(); + final oldStart = int.parse(hunkMatch.group(1)!); + final oldCount = int.tryParse(hunkMatch.group(2) ?? '1') ?? 1; + final newStart = int.parse(hunkMatch.group(3)!); + final newCount = int.tryParse(hunkMatch.group(4) ?? '1') ?? 1; + final header = line; + + currentHunk = DiffHunk( + header: header, + oldStart: oldStart, + oldLines: oldCount, + newStart: newStart, + newLines: newCount, + lines: const [], + ); + currentHunkLines = []; + oldLineNum = oldStart; + newLineNum = newStart; + continue; + } + + // Diff content lines + if (currentHunkLines != null) { + if (line.startsWith('+')) { + currentHunkLines!.add(DiffLine( + type: DiffLineType.added, + content: line.substring(1), + newLineNumber: newLineNum++, + )); + } else if (line.startsWith('-')) { + currentHunkLines!.add(DiffLine( + type: DiffLineType.removed, + content: line.substring(1), + oldLineNumber: oldLineNum++, + )); + } else if (line.startsWith(' ')) { + currentHunkLines!.add(DiffLine( + type: DiffLineType.context, + content: line.substring(1), + oldLineNumber: oldLineNum++, + newLineNumber: newLineNum++, + )); + } + } + } + + flushFile(); + return files; + } + + static FileChangeStatus _inferStatus(String oldPath, String newPath) { + if (oldPath == '/dev/null') return FileChangeStatus.added; + if (newPath == '/dev/null') return FileChangeStatus.deleted; + if (oldPath != newPath) return FileChangeStatus.renamed; + return FileChangeStatus.modified; + } +} diff --git a/apps/mobile/lib/shared/widgets/code_block.dart b/apps/mobile/lib/shared/widgets/code_block.dart new file mode 100644 index 0000000..7ce06c0 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/code_block.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../constants/colors.dart'; +import '../constants/typography.dart'; + +class CodeBlock extends StatelessWidget { + final String code; + final String? language; + final bool showCopyButton; + + const CodeBlock({ + super.key, + required this.code, + this.language, + this.showCopyButton = true, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: kSurfaceVariant, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: kBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (language != null || showCopyButton) + _buildHeader(context), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(12), + child: Text( + code, + style: AppTypography.code, + ), + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: kBorder)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (language != null) + Text( + language!, + style: AppTypography.codeSmall, + ) + else + const SizedBox.shrink(), + if (showCopyButton) + _CopyButton(code: code), + ], + ), + ); + } +} + +class _CopyButton extends StatefulWidget { + final String code; + const _CopyButton({required this.code}); + + @override + State<_CopyButton> createState() => _CopyButtonState(); +} + +class _CopyButtonState extends State<_CopyButton> { + bool _copied = false; + + Future _onCopy() async { + await Clipboard.setData(ClipboardData(text: widget.code)); + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onCopy, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _copied ? Icons.check : Icons.copy, + size: 14, + color: kTextSecondary, + ), + const SizedBox(width: 4), + Text( + _copied ? 'Copied' : 'Copy', + style: AppTypography.codeSmall, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/connection_status_bar.dart b/apps/mobile/lib/shared/widgets/connection_status_bar.dart new file mode 100644 index 0000000..3c0cd85 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/connection_status_bar.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/network/connection_state.dart'; +import '../../core/providers/websocket_provider.dart'; + +class ConnectionStatusBar extends ConsumerWidget { + const ConnectionStatusBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statusAsync = ref.watch(connectionStatusProvider); + return statusAsync.when( + data: (status) => _buildBar(status), + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + + Widget _buildBar(ConnectionStatus status) { + if (status == ConnectionStatus.connected) return const SizedBox.shrink(); + + final isReconnecting = status == ConnectionStatus.reconnecting; + final color = isReconnecting + ? const Color(0xFFFF9800) + : const Color(0xFFF44747); + final message = isReconnecting ? 'Reconnecting...' : 'Offline'; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + color: color, + padding: const EdgeInsets.symmetric(vertical: 3), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/empty_state.dart b/apps/mobile/lib/shared/widgets/empty_state.dart new file mode 100644 index 0000000..f61a8a7 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/empty_state.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 56, + color: theme.colorScheme.onSurface.withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + title, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 20), + FilledButton( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/error_card.dart b/apps/mobile/lib/shared/widgets/error_card.dart new file mode 100644 index 0000000..5bc6b3c --- /dev/null +++ b/apps/mobile/lib/shared/widgets/error_card.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class ErrorCard extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const ErrorCard({ + super.key, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 32, + ), + const SizedBox(height: 8), + Semantics( + label: message, + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + ), + if (onRetry != null) ...[ + const SizedBox(height: 12), + Semantics( + label: 'Retry', + button: true, + child: TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/expandable_card.dart b/apps/mobile/lib/shared/widgets/expandable_card.dart new file mode 100644 index 0000000..7856448 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/expandable_card.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class ExpandableCard extends StatefulWidget { + final Widget header; + final Widget content; + final bool initiallyExpanded; + + const ExpandableCard({ + super.key, + required this.header, + required this.content, + this.initiallyExpanded = false, + }); + + @override + State createState() => _ExpandableCardState(); +} + +class _ExpandableCardState extends State + with SingleTickerProviderStateMixin { + late bool _expanded; + late AnimationController _controller; + late Animation _chevronRotation; + late Animation _expandAnimation; + + @override + void initState() { + super.initState(); + _expanded = widget.initiallyExpanded; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + value: _expanded ? 1.0 : 0.0, + ); + _chevronRotation = Tween(begin: 0.0, end: 0.5).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() { + _expanded = !_expanded; + if (_expanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: _toggle, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Expanded(child: widget.header), + RotationTransition( + turns: _chevronRotation, + child: const Icon(Icons.expand_more), + ), + ], + ), + ), + ), + SizeTransition( + sizeFactor: _expandAnimation, + child: Column( + children: [ + const Divider(height: 1), + widget.content, + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/loading_indicator.dart b/apps/mobile/lib/shared/widgets/loading_indicator.dart new file mode 100644 index 0000000..f2689f0 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/loading_indicator.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class LoadingIndicator extends StatelessWidget { + final String? message; + + const LoadingIndicator({super.key, this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: message ?? 'Loading', + child: const CircularProgressIndicator(), + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/markdown_view.dart b/apps/mobile/lib/shared/widgets/markdown_view.dart new file mode 100644 index 0000000..2d1a7e6 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/markdown_view.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import '../constants/colors.dart'; +import '../constants/typography.dart'; + +class MarkdownView extends StatelessWidget { + final String data; + final bool shrinkWrap; + + const MarkdownView({ + super.key, + required this.data, + this.shrinkWrap = false, + }); + + @override + Widget build(BuildContext context) { + return Markdown( + data: data, + shrinkWrap: shrinkWrap, + styleSheet: _buildStyleSheet(context), + padding: EdgeInsets.zero, + ); + } + + MarkdownStyleSheet _buildStyleSheet(BuildContext context) { + final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); + return base.copyWith( + code: AppTypography.code, + codeblockDecoration: BoxDecoration( + color: kSurfaceVariant, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: kBorder), + ), + codeblockPadding: const EdgeInsets.all(12), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: kPrimary.withOpacity(0.6), + width: 3, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/risk_badge.dart b/apps/mobile/lib/shared/widgets/risk_badge.dart new file mode 100644 index 0000000..d7729ed --- /dev/null +++ b/apps/mobile/lib/shared/widgets/risk_badge.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../constants/colors.dart'; + +class RiskBadge extends StatelessWidget { + final String riskLevel; + + const RiskBadge({super.key, required this.riskLevel}); + + Color get _color => switch (riskLevel.toLowerCase()) { + 'low' => kRiskLow, + 'medium' => kRiskMedium, + 'high' => kRiskHigh, + 'critical' => kRiskCritical, + _ => kRiskMedium, + }; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _color.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: _color.withOpacity(0.6)), + ), + child: Text( + riskLevel.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: _color, + letterSpacing: 0.5, + ), + ), + ); + } +} diff --git a/apps/mobile/lib/shared/widgets/tool_icon.dart b/apps/mobile/lib/shared/widgets/tool_icon.dart new file mode 100644 index 0000000..f8b2261 --- /dev/null +++ b/apps/mobile/lib/shared/widgets/tool_icon.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class ToolIcon extends StatelessWidget { + final String tool; + final double size; + final Color? color; + + const ToolIcon({ + super.key, + required this.tool, + this.size = 24, + this.color, + }); + + IconData get _icon => switch (tool.toLowerCase()) { + 'edit_file' || 'write' || 'edit' => Icons.edit, + 'read_file' || 'read' => Icons.file_open, + 'bash' || 'run_command' || 'bash_command' => Icons.terminal, + 'glob' => Icons.folder_open, + 'grep' => Icons.search, + 'list_files' || 'ls' => Icons.list, + 'git_commit' => Icons.commit, + 'git_diff' => Icons.difference, + _ => Icons.build, + }; + + @override + Widget build(BuildContext context) { + return Icon( + _icon, + size: size, + color: color ?? Theme.of(context).iconTheme.color, + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 359ec32..ee2fb9e 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1,141 +1,961 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: - name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + drift: + dependency: "direct main" + description: + name: drift + sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1" + url: "https://pub.dev" + source: hosted + version: "2.28.2" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4" + url: "https://pub.dev" + source: hosted + version: "2.28.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: b7534bf320aac5213259aac120670ba67b63a1fd010505babc436ff86083818f + url: "https://pub.dev" + source: hosted + version: "0.2.7" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + url: "https://pub.dev" + source: hosted + version: "5.4.6" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "2.13.0" - boolean_selector: + version: "9.3.0" + package_config: dependency: transitive description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.2" - characters: + version: "2.2.0" + package_info_plus: dependency: transitive description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "1.4.0" - clock: + version: "9.0.0" + package_info_plus_platform_interface: dependency: transitive description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "1.1.2" - collection: + version: "3.2.1" + path: dependency: transitive description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.19.1" - fake_async: + version: "1.9.1" + path_parsing: dependency: transitive description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" url: "https://pub.dev" source: hosted - version: "1.3.3" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" + version: "1.1.0" + path_provider: + dependency: transitive description: - name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" url: "https://pub.dev" source: hosted - version: "3.0.2" - flutter_test: + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + patrol: dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - leak_tracker: + description: + name: patrol + sha256: "32fd0709f3871fa56eb9cd88410e3ca816bfa757122bae806a0f842188acb820" + url: "https://pub.dev" + source: hosted + version: "3.20.0" + patrol_finders: dependency: transitive description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + name: patrol_finders + sha256: "4a658d7d560de523f92deb3fa3326c78747ca0bf7e7f4b8788c012463138b628" url: "https://pub.dev" source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: + version: "2.9.0" + patrol_log: dependency: transitive description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + name: patrol_log + sha256: "9fed4143980df1e3bbcfa00d0b443c7d68f04f9132317b7698bbc37f8a5a58c5" url: "https://pub.dev" source: hosted - version: "3.0.10" - leak_tracker_testing: + version: "0.5.0" + pedantic: dependency: transitive description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" url: "https://pub.dev" source: hosted - version: "3.0.2" - lints: + version: "1.11.1" + petitparser: dependency: transitive description: - name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "3.0.0" - matcher: + version: "7.0.2" + platform: dependency: transitive description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "0.12.17" - material_color_utilities: + version: "3.1.6" + plugin_platform_interface: dependency: transitive description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "0.11.1" - meta: + version: "2.1.8" + pool: dependency: transitive description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.17.0" - path: + version: "1.5.2" + process: dependency: transitive description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.dev" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + sentry: + dependency: transitive + description: + name: sentry + sha256: "605ad1f6f1ae5b72018cbe8fc20f490fa3bd53e58882e5579566776030d8c8c1" + url: "https://pub.dev" + source: hosted + version: "9.14.0" + sentry_flutter: + dependency: "direct main" + description: + name: sentry_flutter + sha256: "7fd0fb80050c1f6a77ae185bda997a76d384326d6777cf5137a6c38952c4ac7d" + url: "https://pub.dev" + source: hosted + version: "9.14.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.dev" + source: hosted + version: "1.3.7" source_span: dependency: transitive description: @@ -144,6 +964,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + speech_to_text: + dependency: "direct main" + description: + name: speech_to_text + sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04 + url: "https://pub.dev" + source: hosted + version: "7.3.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072" + url: "https://pub.dev" + source: hosted + version: "1.0.0+beta.8" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67" + url: "https://pub.dev" + source: hosted + version: "0.41.2" stack_trace: dependency: transitive description: @@ -152,6 +1020,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -160,6 +1036,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -168,6 +1052,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -184,6 +1076,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" + url: "https://pub.dev" + source: hosted + version: "1.1.20" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" vector_math: dependency: transitive description: @@ -200,6 +1148,78 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 020e5e6..d271bd8 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -1,12 +1,8 @@ name: recursor_mobile - -description: ReCursor Flutter mobile app (scaffold only). +description: ReCursor Flutter mobile app — OpenCode-style UI for AI coding agents. repository: https://github.com/RecursiveDev/ReCursor - -# Prevent accidental publishing. publish_to: "none" - -version: 0.0.0 +version: 0.1.0+1 environment: sdk: ">=3.3.0 <4.0.0" @@ -15,10 +11,75 @@ dependencies: flutter: sdk: flutter + # State management + flutter_riverpod: ^2.5.1 + riverpod_annotation: ^2.3.5 + + # Immutable models + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 + + # Local database + drift: ^2.18.0 + drift_flutter: ^0.2.0 + sqlite3_flutter_libs: ^0.5.0 + + # Key-value cache + hive_flutter: ^1.1.0 + + # Networking + web_socket_channel: ^3.0.0 + + # Secure storage + flutter_secure_storage: ^9.2.2 + + # Navigation + go_router: ^14.2.0 + + # Markdown rendering + flutter_markdown: ^0.7.4 + + # QR scanning + mobile_scanner: ^7.2.0 + + # Voice input + speech_to_text: ^7.3.0 + + # Local notifications + flutter_local_notifications: ^17.2.2 + + # Crash reporting + sentry_flutter: ^9.14.0 + + # SVG support + flutter_svg: ^2.0.10 + + # Utilities + uuid: ^4.4.0 + intl: ^0.19.0 + dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^3.0.0 + build_runner: ^2.4.11 + freezed: ^2.5.7 + riverpod_generator: ^2.4.3 + drift_dev: ^2.18.0 + json_serializable: ^6.8.0 + mockito: ^5.4.4 + patrol: ^3.14.0 flutter: uses-material-design: true + assets: + - assets/branding/ + - assets/fonts/ + fonts: + - family: JetBrainsMono + fonts: + - asset: assets/fonts/JetBrainsMono-Regular.ttf + - asset: assets/fonts/JetBrainsMono-Bold.ttf + weight: 700 diff --git a/apps/mobile/test/core/network/bridge_connection_validator_test.dart b/apps/mobile/test/core/network/bridge_connection_validator_test.dart new file mode 100644 index 0000000..b4418c6 --- /dev/null +++ b/apps/mobile/test/core/network/bridge_connection_validator_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/bridge_connection_validator.dart'; + +void main() { + group('BridgeConnectionValidator', () { + test('returns valid for proper wss URL and token', () { + final result = BridgeConnectionValidator.validate( + url: 'wss://device.tailnet.ts.net:3000', + token: 'valid-token-123', + ); + + expect(result.isValid, isTrue); + expect(result.errorMessage, isNull); + }); + + test('returns invalid when URL is empty', () { + final result = BridgeConnectionValidator.validate( + url: '', + token: 'token', + ); + + expect(result.isValid, isFalse); + expect(result.errorMessage, contains('Bridge URL is required')); + }); + + test('returns invalid when token is empty', () { + final result = BridgeConnectionValidator.validate( + url: 'wss://example.com', + token: '', + ); + + expect(result.isValid, isFalse); + expect(result.errorMessage, contains('token is required')); + }); + + test('returns invalid when URL has no scheme', () { + final result = BridgeConnectionValidator.validate( + url: 'device.tailnet.ts.net:3000', + token: 'token', + ); + + expect(result.isValid, isFalse); + expect(result.errorMessage, contains('valid bridge URL')); + }); + + test('returns invalid when URL uses http', () { + final result = BridgeConnectionValidator.validate( + url: 'http://device.tailnet.ts.net:3000', + token: 'token', + ); + + expect(result.isValid, isFalse); + expect(result.errorMessage, contains('wss://')); + }); + + test('returns invalid when URL uses ws (non-secure)', () { + final result = BridgeConnectionValidator.validate( + url: 'ws://device.tailnet.ts.net:3000', + token: 'token', + ); + + expect(result.isValid, isFalse); + expect(result.errorMessage, contains('must use wss://')); + }); + + test('trims whitespace from URL and token before validation', () { + final result = BridgeConnectionValidator.validate( + url: ' wss://device.tailnet.ts.net:3000 ', + token: ' token-with-spaces ', + ); + + expect(result.isValid, isTrue); + }); + + test('returns valid for localhost wss with port', () { + final result = BridgeConnectionValidator.validate( + url: 'wss://localhost:3000', + token: 'local-token', + ); + + expect(result.isValid, isTrue); + }); + + test('returns invalid for malformed URI', () { + final result = BridgeConnectionValidator.validate( + url: 'not a valid url', + token: 'token', + ); + + expect(result.isValid, isFalse); + }); + }); + + group('BridgeConnectionException', () { + test('stores and returns message', () { + const exception = BridgeConnectionException('Test error message'); + + expect(exception.message, 'Test error message'); + expect(exception.toString(), 'Test error message'); + }); + }); +} diff --git a/apps/mobile/test/core/network/websocket_service_test.dart b/apps/mobile/test/core/network/websocket_service_test.dart new file mode 100644 index 0000000..b828483 --- /dev/null +++ b/apps/mobile/test/core/network/websocket_service_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/bridge_connection_validator.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() { + group('WebSocketService', () { + test('sends auth and waits for connection_ack before becoming connected', + () async { + final fakeChannel = FakeWebSocketChannel(); + final service = WebSocketService(channelFactory: (_) => fakeChannel); + final statuses = []; + + final statusSub = service.connectionStatus.listen(statuses.add); + final Future connectFuture = service.connect( + url: 'wss://device.tailnet.ts.net:3000', + token: 'bridge-token-123', + ); + + await Future.delayed(Duration.zero); + + expect(statuses.first, ConnectionStatus.connecting); + expect(fakeChannel.sentMessages, hasLength(1)); + + final authFrame = + jsonDecode(fakeChannel.sentMessages.single) as Map; + expect(authFrame['type'], 'auth'); + expect( + (authFrame['payload'] as Map)['token'], + 'bridge-token-123', + ); + + fakeChannel.addIncoming( + jsonEncode({ + 'type': 'connection_ack', + 'timestamp': DateTime.now().toUtc().toIso8601String(), + 'payload': { + 'server_version': '1.0.0', + 'supported_agents': [], + 'active_sessions': >[], + }, + }), + ); + + await connectFuture; + + expect(service.currentStatus, ConnectionStatus.connected); + expect(statuses, contains(ConnectionStatus.connected)); + + await statusSub.cancel(); + service.dispose(); + await fakeChannel.close(); + }); + + test('surfaces connection_error without scheduling reconnects', () async { + final fakeChannel = FakeWebSocketChannel(); + var connectionAttempts = 0; + final service = WebSocketService(channelFactory: (_) { + connectionAttempts++; + return fakeChannel; + }); + + final Future connectFuture = service.connect( + url: 'wss://device.tailnet.ts.net:3000', + token: 'bridge-token-123', + ); + + await Future.delayed(Duration.zero); + + fakeChannel.addIncoming( + jsonEncode({ + 'type': 'connection_error', + 'timestamp': DateTime.now().toUtc().toIso8601String(), + 'payload': { + 'code': 'AUTH_FAILED', + 'message': 'Invalid or expired token', + }, + }), + ); + + await expectLater( + connectFuture, + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Invalid or expired token', + ), + ), + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + expect(service.currentStatus, ConnectionStatus.error); + expect(connectionAttempts, 1); + + service.dispose(); + await fakeChannel.close(); + }); + }); +} + +class FakeWebSocketChannel implements WebSocketChannel { + FakeWebSocketChannel({Future? ready}) + : ready = ready ?? Future.value(), + _incomingController = StreamController.broadcast(), + _sink = FakeWebSocketSink([]); + + final StreamController _incomingController; + final FakeWebSocketSink _sink; + + @override + final Future ready; + + List get sentMessages => _sink.messages; + + @override + int? get closeCode => null; + + @override + String? get closeReason => null; + + @override + String? get protocol => null; + + @override + Stream get stream => _incomingController.stream; + + @override + WebSocketSink get sink => _sink; + + void addIncoming(String message) { + _incomingController.add(message); + } + + Future close() async { + await _sink.close(); + await _incomingController.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + return super.noSuchMethod(invocation); + } +} + +class FakeWebSocketSink implements WebSocketSink { + FakeWebSocketSink(this.messages); + + final List messages; + final Completer _doneCompleter = Completer(); + + @override + Future get done => _doneCompleter.future; + + @override + void add(Object? data) { + messages.add(data as String); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close([int? closeCode, String? closeReason]) async { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + } +} diff --git a/apps/mobile/test/core/sync/sync_queue_test.dart b/apps/mobile/test/core/sync/sync_queue_test.dart new file mode 100644 index 0000000..e44fb5a --- /dev/null +++ b/apps/mobile/test/core/sync/sync_queue_test.dart @@ -0,0 +1,179 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_messages.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/storage/database.dart'; +import 'package:recursor_mobile/core/sync/sync_queue.dart'; + +void main() { + group('SyncQueueService', () { + late AppDatabase database; + late SyncQueueService service; + + setUp(() { + database = AppDatabase.inMemory(); + service = SyncQueueService(database: database); + }); + + tearDown(() async { + await database.close(); + }); + + test('keeps queued items pending when the bridge is unavailable', () async { + await service.enqueue( + 'message', + { + 'session_id': 'sess-1', + 'content': 'hello', + 'role': 'user', + }, + sessionId: 'sess-1', + ); + + final webSocket = FakeSyncWebSocketService( + status: ConnectionStatus.disconnected, + sendResult: false, + ); + + await service.flush(webSocket); + + final queuedItems = await database.select(database.syncQueue).get(); + final item = queuedItems.single; + + expect(item.synced, isFalse); + expect(item.retryCount, 1); + expect(item.lastError, 'Bridge unavailable'); + expect(webSocket.sentMessages, isEmpty); + }); + + test('replays queued message payloads using the bridge protocol', () async { + await service.enqueue( + 'message', + { + 'session_id': 'sess-1', + 'content': 'hello', + 'role': 'user', + }, + sessionId: 'sess-1', + ); + + final webSocket = FakeSyncWebSocketService( + status: ConnectionStatus.connected, + sendResult: true, + ); + + await service.flush(webSocket); + + final queuedItems = await database.select(database.syncQueue).get(); + final item = queuedItems.single; + + expect(item.synced, isTrue); + expect(item.retryCount, 0); + expect(webSocket.sentMessages, hasLength(1)); + expect(webSocket.sentMessages.single.type, BridgeMessageType.message); + expect(webSocket.sentMessages.single.payload['session_id'], 'sess-1'); + expect(webSocket.sentMessages.single.payload['content'], 'hello'); + expect(webSocket.sentMessages.single.payload['role'], 'user'); + }); + + test('marks local messages as synced after a successful flush', () async { + await database.sessionDao.upsertSession( + SessionsCompanion( + id: const Value('sess-1'), + agentType: const Value('claude-code'), + title: const Value('Queued Session'), + workingDirectory: const Value('/workspace/queued'), + status: const Value('active'), + createdAt: Value(DateTime.now().toUtc()), + updatedAt: Value(DateTime.now().toUtc()), + synced: const Value(true), + ), + ); + await database.messageDao.insertMessage( + MessagesCompanion( + id: const Value('msg-1'), + sessionId: const Value('sess-1'), + role: const Value('user'), + content: const Value('hello'), + messageType: const Value('text'), + createdAt: Value(DateTime.now().toUtc()), + updatedAt: Value(DateTime.now().toUtc()), + synced: const Value(false), + ), + ); + await service.enqueue( + 'message', + { + 'session_id': 'sess-1', + 'content': 'hello', + 'role': 'user', + 'local_message_id': 'msg-1', + }, + sessionId: 'sess-1', + ); + + final webSocket = FakeSyncWebSocketService( + status: ConnectionStatus.connected, + sendResult: true, + ); + + await service.flush(webSocket); + + final storedMessages = + await database.messageDao.getMessagesForSession('sess-1'); + expect(storedMessages.single.synced, isTrue); + }); + + test('replays queued session starts with the provided client session id', + () async { + await service.enqueue( + 'session_start', + { + 'agent': 'claude-code', + 'session_id': 'sess-queued', + 'working_directory': '/workspace/app', + 'resume': false, + }, + sessionId: 'sess-queued', + ); + + final webSocket = FakeSyncWebSocketService( + status: ConnectionStatus.connected, + sendResult: true, + ); + + await service.flush(webSocket); + + expect(webSocket.sentMessages, hasLength(1)); + expect( + webSocket.sentMessages.single.type, BridgeMessageType.sessionStart); + expect( + webSocket.sentMessages.single.payload['session_id'], 'sess-queued'); + expect( + webSocket.sentMessages.single.payload['working_directory'], + '/workspace/app', + ); + }); + }); +} + +class FakeSyncWebSocketService extends WebSocketService { + FakeSyncWebSocketService({ + required ConnectionStatus status, + required this.sendResult, + }) : _status = status; + + final ConnectionStatus _status; + final bool sendResult; + final List sentMessages = []; + + @override + ConnectionStatus get currentStatus => _status; + + @override + bool send(BridgeMessage message) { + sentMessages.add(message); + return sendResult; + } +} diff --git a/apps/mobile/test/features/chat/chat_provider_test.dart b/apps/mobile/test/features/chat/chat_provider_test.dart new file mode 100644 index 0000000..b8de9d4 --- /dev/null +++ b/apps/mobile/test/features/chat/chat_provider_test.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_messages.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/providers/database_provider.dart'; +import 'package:recursor_mobile/core/providers/websocket_provider.dart'; +import 'package:recursor_mobile/core/storage/database.dart'; +import 'package:recursor_mobile/features/chat/domain/providers/chat_provider.dart'; + +void main() { + group('ChatNotifier', () { + late AppDatabase database; + late FakeChatWebSocketService webSocketService; + late ProviderContainer container; + late ProviderSubscription> chatNotifierSubscription; + + setUp(() async { + database = AppDatabase.inMemory(); + webSocketService = FakeChatWebSocketService( + initialStatus: ConnectionStatus.connected, + ); + container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(database), + webSocketServiceProvider.overrideWithValue(webSocketService), + ], + ); + chatNotifierSubscription = container.listen>( + chatNotifierProvider, + (_, __) {}, + fireImmediately: true, + ); + addTearDown(chatNotifierSubscription.close); + addTearDown(container.dispose); + await container.read(chatNotifierProvider.future); + }); + + tearDown(() async { + await webSocketService.close(); + await database.close(); + }); + + test('persists session readiness, tool events, and streamed responses', + () async { + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.sessionReady, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'agent': 'claude-code', + 'working_directory': '/workspace/project-alpha', + 'status': 'ready', + 'model': 'claude-sonnet', + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.approvalRequired, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'tool_call_id': 'tool-1', + 'tool': 'edit_file', + 'params': {'path': 'lib/main.dart'}, + 'description': 'Approval required for edit_file', + 'risk_level': 'medium', + 'source': 'agent_sdk', + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.toolResult, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'tool_call_id': 'tool-1', + 'tool': 'edit_file', + 'result': { + 'success': true, + 'content': 'Updated lib/main.dart', + 'duration_ms': 18, + }, + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.streamStart, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'message_id': 'msg-1', + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.streamChunk, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'message_id': 'msg-1', + 'content': 'Hello', + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.streamChunk, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'message_id': 'msg-1', + 'content': ' world', + }, + ), + ); + webSocketService.emitMessage( + BridgeMessage( + type: BridgeMessageType.streamEnd, + timestamp: DateTime.now().toUtc(), + payload: { + 'session_id': 'sess-stream', + 'message_id': 'msg-1', + 'finish_reason': 'stop', + }, + ), + ); + + await _drainQueue(); + + final session = await database.sessionDao.getSession('sess-stream'); + final messages = + await database.messageDao.getMessagesForSession('sess-stream'); + + expect(session, isNotNull); + expect(session!.title, 'project-alpha'); + expect(messages, hasLength(3)); + expect( + messages.map((message) => message.messageType), + containsAll(['toolCall', 'toolResult', 'text']), + ); + expect( + messages.any((message) => message.content == 'Hello world'), + isTrue, + ); + }); + + test('queues outgoing messages offline and flushes them on reconnect', + () async { + await database.sessionDao.upsertSession( + SessionsCompanion( + id: const Value('sess-offline'), + agentType: const Value('claude-code'), + title: const Value('Offline Session'), + workingDirectory: const Value('/workspace/offline'), + status: const Value('active'), + createdAt: Value(DateTime.now().toUtc()), + updatedAt: Value(DateTime.now().toUtc()), + synced: const Value(true), + ), + ); + + webSocketService.currentStatusValue = ConnectionStatus.disconnected; + + await container + .read(chatNotifierProvider.notifier) + .sendMessage('sess-offline', 'Queued hello'); + await _drainQueue(); + + var queuedItems = await database.select(database.syncQueue).get(); + var storedMessages = + await database.messageDao.getMessagesForSession('sess-offline'); + + expect(queuedItems, hasLength(1)); + expect(queuedItems.single.operation, 'message'); + expect(queuedItems.single.synced, isFalse); + expect(storedMessages.single.synced, isFalse); + expect(webSocketService.sentMessages, isEmpty); + + webSocketService.currentStatusValue = ConnectionStatus.connected; + webSocketService.emitStatus(ConnectionStatus.connected); + await _drainQueue(); + + queuedItems = await database.select(database.syncQueue).get(); + storedMessages = + await database.messageDao.getMessagesForSession('sess-offline'); + + expect(queuedItems.single.synced, isTrue); + expect(storedMessages.single.synced, isTrue); + expect(webSocketService.sentMessages, hasLength(1)); + expect( + webSocketService.sentMessages.single.type, BridgeMessageType.message); + expect( + webSocketService.sentMessages.single.payload['content'], + 'Queued hello', + ); + }); + }); +} + +Future _drainQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(const Duration(milliseconds: 20)); +} + +class FakeChatWebSocketService extends WebSocketService { + FakeChatWebSocketService({required ConnectionStatus initialStatus}) + : _currentStatus = initialStatus; + + final StreamController _messageController = + StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); + final List sentMessages = []; + + ConnectionStatus _currentStatus; + Map? _lastConnectionAckPayload; + bool sendResult = true; + + set currentStatusValue(ConnectionStatus value) { + _currentStatus = value; + } + + @override + Stream get messages => _messageController.stream; + + @override + Stream get connectionStatus => _statusController.stream; + + @override + ConnectionStatus get currentStatus => _currentStatus; + + @override + Map? get lastConnectionAckPayload => + _lastConnectionAckPayload; + + @override + bool send(BridgeMessage message) { + if (_currentStatus != ConnectionStatus.connected) { + return false; + } + sentMessages.add(message); + return sendResult; + } + + void emitMessage(BridgeMessage message) { + if (message.type == BridgeMessageType.connectionAck) { + _lastConnectionAckPayload = message.payload; + } + _messageController.add(message); + } + + void emitStatus(ConnectionStatus status) { + _currentStatus = status; + _statusController.add(status); + } + + Future close() async { + await _messageController.close(); + await _statusController.close(); + } +} diff --git a/apps/mobile/test/features/chat/tool_card_test.dart b/apps/mobile/test/features/chat/tool_card_test.dart new file mode 100644 index 0000000..3e81747 --- /dev/null +++ b/apps/mobile/test/features/chat/tool_card_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/models/message_models.dart'; +import 'package:recursor_mobile/features/chat/presentation/widgets/tool_card.dart'; + +void main() { + group('ToolCard', () { + testWidgets('shows running state without metadata', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'edit_file', + params: {'path': 'lib/main.dart'}, + id: 'tool-1', + isCompleted: false, + ), + ), + ), + ); + + expect(find.text('edit_file'), findsOneWidget); + expect(find.byIcon(Icons.hourglass_empty), findsOneWidget); + }); + + testWidgets('shows approval required state with risk level badge', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'edit_file', + params: {'path': 'lib/main.dart'}, + id: 'tool-1', + isCompleted: false, + metadata: { + 'risk_level': 'high', + 'source': 'agent_sdk', + }, + ), + ), + ), + ); + + expect(find.text('edit_file'), findsOneWidget); + // High risk badge should be visible + expect(find.text('HIGH'), findsOneWidget); + // Approvals banner should be visible + expect(find.textContaining('Approval required'), findsOneWidget); + // Pending icon appears twice (in header and banner) + expect(find.byIcon(Icons.pending_actions), findsNWidgets(2)); + }); + + testWidgets('shows completed state with result', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'edit_file', + params: {'path': 'lib/main.dart'}, + id: 'tool-1', + isCompleted: true, + result: ToolResult( + success: true, + content: 'File updated successfully', + durationMs: 150, + ), + ), + ), + ), + ); + + expect(find.text('edit_file'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('shows failed state with error', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'edit_file', + params: {'path': 'lib/main.dart'}, + id: 'tool-1', + isCompleted: true, + result: ToolResult( + success: false, + content: '', + error: 'Permission denied', + durationMs: 50, + ), + ), + ), + ), + ); + + expect(find.text('edit_file'), findsOneWidget); + expect(find.byIcon(Icons.error), findsOneWidget); + }); + + testWidgets('shows medium risk level badge', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'bash', + params: {'command': 'ls -la'}, + id: 'tool-2', + isCompleted: false, + metadata: {'risk_level': 'medium'}, + ), + ), + ), + ); + + expect(find.text('MEDIUM'), findsOneWidget); + }); + + testWidgets('shows critical risk level badge', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'bash', + params: {'command': 'rm -rf /'}, + id: 'tool-3', + isCompleted: false, + metadata: {'risk_level': 'critical'}, + ), + ), + ), + ); + + expect(find.text('CRITICAL'), findsOneWidget); + }); + + testWidgets('expands parameters on tap', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'edit_file', + params: { + 'path': 'lib/main.dart', + 'new_content': 'void main() {}' + }, + id: 'tool-1', + isCompleted: false, + ), + ), + ), + ); + + // Parameters section should be present + expect(find.text('Parameters'), findsOneWidget); + + // Tap on Parameters section + await tester.tap(find.text('Parameters')); + await tester.pumpAndSettle(); + + // Key value list should now be visible (check for the widget type) + expect(find.byType(ToolCard), findsOneWidget); + }); + + group('tool icon selection', () { + testWidgets('shows file icon for file operations', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'read_file', + params: {}, + id: 'tool-1', + isCompleted: false, + ), + ), + ), + ); + + expect(find.byType(ToolCard), findsOneWidget); + }); + + testWidgets('shows terminal icon for bash operations', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'bash', + params: {}, + id: 'tool-1', + isCompleted: false, + ), + ), + ), + ); + + expect(find.byType(ToolCard), findsOneWidget); + }); + + testWidgets('shows git icon for git operations', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ToolCard( + toolName: 'git_status', + params: {}, + id: 'tool-1', + isCompleted: false, + ), + ), + ), + ); + + expect(find.byType(ToolCard), findsOneWidget); + }); + }); + }); +} diff --git a/apps/mobile/test/features/startup/bridge_startup_controller_test.dart b/apps/mobile/test/features/startup/bridge_startup_controller_test.dart new file mode 100644 index 0000000..fa4a5bd --- /dev/null +++ b/apps/mobile/test/features/startup/bridge_startup_controller_test.dart @@ -0,0 +1,144 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/bridge_connection_validator.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/storage/preferences.dart'; +import 'package:recursor_mobile/core/storage/secure_token_storage.dart'; +import 'package:recursor_mobile/features/startup/domain/bridge_startup_controller.dart'; + +void main() { + group('BridgeStartupController', () { + test('returns bridge setup when no saved pairing exists', () async { + final controller = BridgeStartupController( + preferences: FakeAppPreferences(), + tokenStorage: FakeSecureTokenStorage(), + webSocketService: FakeStartupWebSocketService(), + ); + + final result = await controller.restore(); + + expect(result.destination, AppStartupDestination.bridgeSetup); + expect(result.message, isNull); + }); + + test('returns bridge setup with validation error when URL is invalid', + () async { + final controller = BridgeStartupController( + preferences: FakeAppPreferences(bridgeUrl: 'http://invalid.com'), + tokenStorage: FakeSecureTokenStorage(token: 'token-123'), + webSocketService: FakeStartupWebSocketService(), + ); + + final result = await controller.restore(); + + expect(result.destination, AppStartupDestination.bridgeSetup); + expect(result.message, contains('Invalid saved bridge configuration')); + }); + + test('returns bridge setup with validation error when URL is not wss', + () async { + final controller = BridgeStartupController( + preferences: FakeAppPreferences(bridgeUrl: 'ws://device.ts.net:3000'), + tokenStorage: FakeSecureTokenStorage(token: 'token-123'), + webSocketService: FakeStartupWebSocketService(), + ); + + final result = await controller.restore(); + + expect(result.destination, AppStartupDestination.bridgeSetup); + expect(result.message, contains('must use wss://')); + }); + + test('restores the saved bridge pairing and opens the home shell', + () async { + final service = FakeStartupWebSocketService(); + final controller = BridgeStartupController( + preferences: FakeAppPreferences( + bridgeUrl: 'wss://device.tailnet.ts.net:3000', + ), + tokenStorage: FakeSecureTokenStorage(token: 'bridge-token-123'), + webSocketService: service, + ); + + final result = await controller.restore(); + + expect(result.destination, AppStartupDestination.home); + expect(service.lastUrl, 'wss://device.tailnet.ts.net:3000'); + expect(service.lastToken, 'bridge-token-123'); + expect(service.currentStatus, ConnectionStatus.connected); + }); + + test('falls back to bridge setup when reconnecting fails', () async { + final controller = BridgeStartupController( + preferences: FakeAppPreferences( + bridgeUrl: 'wss://device.tailnet.ts.net:3000', + ), + tokenStorage: FakeSecureTokenStorage(token: 'bridge-token-123'), + webSocketService: FakeStartupWebSocketService( + error: const BridgeConnectionException('Invalid token'), + ), + ); + + final result = await controller.restore(); + + expect(result.destination, AppStartupDestination.bridgeSetup); + expect(result.message, contains('Unable to reconnect')); + expect(result.message, contains('Invalid token')); + }); + }); +} + +class FakeAppPreferences extends AppPreferences { + FakeAppPreferences({this.bridgeUrl}); + + String? bridgeUrl; + + @override + String? getBridgeUrl() { + return bridgeUrl; + } + + @override + Future setBridgeUrl(String? url) async { + bridgeUrl = url; + } +} + +class FakeSecureTokenStorage extends SecureTokenStorage { + FakeSecureTokenStorage({this.token}) : super(const FlutterSecureStorage()); + + String? token; + + @override + Future getToken(String key) async { + return token; + } + + @override + Future saveToken(String key, String value) async { + token = value; + } +} + +class FakeStartupWebSocketService extends WebSocketService { + FakeStartupWebSocketService({this.error}); + + final Object? error; + String? lastUrl; + String? lastToken; + ConnectionStatus _currentStatus = ConnectionStatus.disconnected; + + @override + ConnectionStatus get currentStatus => _currentStatus; + + @override + Future connect({required String url, required String token}) async { + lastUrl = url; + lastToken = token; + if (error != null) { + throw error!; + } + _currentStatus = ConnectionStatus.connected; + } +} diff --git a/apps/mobile/test/router_test.dart b/apps/mobile/test/router_test.dart new file mode 100644 index 0000000..4159b27 --- /dev/null +++ b/apps/mobile/test/router_test.dart @@ -0,0 +1,59 @@ +// Router integration smoke test — verifies key feature screen imports resolve. +// Run with: flutter test test/router_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/features/agents/presentation/screens/agent_list_screen.dart'; +import 'package:recursor_mobile/features/approvals/presentation/screens/approval_detail_screen.dart'; +import 'package:recursor_mobile/features/approvals/presentation/screens/approvals_screen.dart'; +import 'package:recursor_mobile/features/git/presentation/screens/git_screen.dart'; +import 'package:recursor_mobile/features/settings/presentation/screens/settings_screen.dart'; +import 'package:recursor_mobile/features/startup/presentation/screens/bridge_setup_screen.dart'; +import 'package:recursor_mobile/features/startup/presentation/screens/splash_screen.dart'; +import 'package:recursor_mobile/features/terminal/presentation/screens/terminal_screen.dart'; + +void main() { + group('Feature Screen Reachability', () { + test('SplashScreen exists and instantiates', () { + const screen = SplashScreen(); + expect(screen, isA()); + }); + + test('BridgeSetupScreen exists and instantiates', () { + const screen = BridgeSetupScreen(); + expect(screen, isA()); + }); + + test('AgentListScreen exists and instantiates', () { + const screen = AgentListScreen(); + expect(screen, isA()); + }); + + test('ApprovalDetailScreen exists and instantiates', () { + const screen = ApprovalDetailScreen(toolCallId: 'test-id'); + expect(screen, isA()); + }); + + test('ApprovalsScreen exists and instantiates', () { + const screen = ApprovalsScreen(); + expect(screen, isA()); + }); + + test('GitScreen exists and instantiates', () { + const screen = GitScreen(sessionId: ''); + expect(screen, isA()); + }); + + test('SettingsScreen exists and instantiates', () { + const screen = SettingsScreen(); + expect(screen, isA()); + }); + + test('TerminalScreen exists and instantiates', () { + const screen = TerminalScreen( + sessionId: 'test-session', + workingDirectory: '~/test', + ); + expect(screen, isA()); + }); + }); +} diff --git a/apps/mobile/test/widget_test.dart b/apps/mobile/test/widget_test.dart new file mode 100644 index 0000000..dd3f8d7 --- /dev/null +++ b/apps/mobile/test/widget_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recursor_mobile/core/network/connection_state.dart'; +import 'package:recursor_mobile/core/network/websocket_messages.dart'; +import 'package:recursor_mobile/core/network/websocket_service.dart'; +import 'package:recursor_mobile/core/providers/websocket_provider.dart'; +import 'package:recursor_mobile/features/chat/presentation/widgets/chat_input_bar.dart'; + +void main() { + testWidgets('offline chat input queues locally instead of blocking send', + (tester) async { + final webSocketService = FakeWidgetWebSocketService( + initialStatus: ConnectionStatus.disconnected, + ); + String? submittedText; + + addTearDown(webSocketService.close); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + webSocketServiceProvider.overrideWithValue(webSocketService), + ], + child: MaterialApp( + home: Scaffold( + body: ChatInputBar( + sessionId: 'sess-widget', + onSend: (text) => submittedText = text, + onVoice: () {}, + ), + ), + ), + ), + ); + await tester.pump(); + + expect( + find.text('Offline — messages will send when reconnected'), + findsOneWidget, + ); + expect(find.byIcon(Icons.schedule_send), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'Queue me'); + await tester.pump(); + await tester.tap(find.byIcon(Icons.schedule_send)); + await tester.pump(); + + expect(submittedText, 'Queue me'); + }); +} + +class FakeWidgetWebSocketService extends WebSocketService { + FakeWidgetWebSocketService({required ConnectionStatus initialStatus}) + : _currentStatus = initialStatus; + + final StreamController _messageController = + StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); + final ConnectionStatus _currentStatus; + + @override + Stream get messages => _messageController.stream; + + @override + Stream get connectionStatus => _statusController.stream; + + @override + ConnectionStatus get currentStatus => _currentStatus; + + @override + bool send(BridgeMessage message) { + return _currentStatus == ConnectionStatus.connected; + } + + Future close() async { + await _messageController.close(); + await _statusController.close(); + } +} diff --git a/docs-site/app.js b/docs-site/app.js new file mode 100644 index 0000000..24ab2c7 --- /dev/null +++ b/docs-site/app.js @@ -0,0 +1,558 @@ +// ===== Page Registry ===== +const pages = [ + { id: 'overview', title: 'Overview', section: 'Getting Started' }, + { id: 'architecture', title: 'Architecture', section: 'Getting Started' }, + { id: 'quickstart', title: 'Quickstart', section: 'Getting Started' }, + { id: 'bridge-protocol', title: 'Bridge Protocol', section: 'Core Concepts' }, + { id: 'data-flow', title: 'Data Flow', section: 'Core Concepts' }, + { id: 'offline', title: 'Offline Architecture', section: 'Core Concepts' }, + { id: 'hooks', title: 'Claude Code Hooks', section: 'Integration' }, + { id: 'agent-sdk', title: 'Agent SDK', section: 'Integration' }, + { id: 'ui-patterns', title: 'OpenCode UI Patterns', section: 'Integration' }, + { id: 'security', title: 'Security Architecture', section: 'Security' }, + { id: 'roadmap', title: 'Roadmap', section: 'Development' }, + { id: 'tech-stack', title: 'Tech Stack', section: 'Development' }, +]; + +// ===== Page Cache & State ===== +const pageCache = {}; // id -> HTML string +const searchIndex = {}; // id -> lowercase text for search +let currentPageIndex = 0; + +// ===== Dynamic Page Loading ===== +async function fetchPage(pageId) { + if (pageCache[pageId]) return pageCache[pageId]; + + try { + const resp = await fetch(`pages/${pageId}.html`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const html = await resp.text(); + pageCache[pageId] = html; + return html; + } catch (err) { + console.error(`Failed to load page: ${pageId}`, err); + return `
`; + } +} + +async function navigateTo(pageId) { + const index = pages.findIndex(p => p.id === pageId); + if (index === -1) return; + currentPageIndex = index; + + const container = document.getElementById('pageContainer'); + + // Show loading state briefly + container.innerHTML = '
'; + + const html = await fetchPage(pageId); + container.innerHTML = html; + + // Build search index for this page if not yet done + if (!searchIndex[pageId]) { + searchIndex[pageId] = container.textContent.toLowerCase(); + } + + // Attach copy handlers on newly-inserted code blocks + attachCopyHandlers(container); + + // Inject "Copy page" button into the page header + injectCopyPageButton(container); + + // Update nav active state + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.toggle('active', item.dataset.page === pageId); + }); + + updateFooterNav(); + window.scrollTo({ top: 0 }); + closeSidebar(); +} + +// ===== Pre-fetch all pages for search index ===== +async function buildSearchIndex() { + await Promise.all(pages.map(async (p) => { + const html = await fetchPage(p.id); + // Parse the text from html for search + const tmp = document.createElement('div'); + tmp.innerHTML = html; + searchIndex[p.id] = tmp.textContent.toLowerCase(); + })); +} + +// ===== Footer Navigation ===== +function updateFooterNav() { + const prevBtn = document.getElementById('footerPrev'); + const nextBtn = document.getElementById('footerNext'); + const prevTitle = document.getElementById('prevTitle'); + const nextTitle = document.getElementById('nextTitle'); + + if (currentPageIndex > 0) { + const prev = pages[currentPageIndex - 1]; + prevBtn.style.display = 'flex'; + prevBtn.href = '#' + prev.id; + prevTitle.textContent = prev.title; + } else { + prevBtn.style.display = 'none'; + } + + if (currentPageIndex < pages.length - 1) { + const next = pages[currentPageIndex + 1]; + nextBtn.style.display = 'flex'; + nextBtn.href = '#' + next.id; + nextTitle.textContent = next.title; + } else { + nextBtn.style.display = 'none'; + } +} + +// ===== Copy Handlers (Code + Markdown) ===== +function attachCopyHandlers(root) { + root.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; // "code" or "markdown" + const block = btn.closest('.code-block'); + const codeEl = block.querySelector('code'); + const rawCode = codeEl.textContent; + + let textToCopy; + if (mode === 'markdown') { + const lang = block.dataset.lang || ''; + // data-md-lang overrides the fence language (e.g. empty for plain text) + const mdLang = block.hasAttribute('data-md-lang') ? block.dataset.mdLang : lang; + textToCopy = '```' + mdLang + '\n' + rawCode + '\n```'; + } else { + textToCopy = rawCode; + } + + writeClipboard(textToCopy, btn); + }); + }); +} + +function writeClipboard(text, btn) { + navigator.clipboard.writeText(text).then(() => { + flashCopied(btn); + }).catch(() => { + // Fallback for non-HTTPS / older browsers + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + flashCopied(btn); + }); +} + +function flashCopied(btn) { + const original = btn.textContent; + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = original; + btn.classList.remove('copied'); + }, 2000); +} + +// ===== Copy Page Button ===== +function injectCopyPageButton(container) { + const header = container.querySelector('.page-header'); + if (!header) return; + + // Don't double-inject + if (header.querySelector('.copy-page-btn')) return; + + const btn = document.createElement('button'); + btn.className = 'copy-page-btn'; + btn.innerHTML = `Copy page`; + btn.addEventListener('click', () => { + const article = container.querySelector('article.page'); + if (!article) return; + const md = pageToMarkdown(article); + writeClipboard(md, btn.querySelector('span')); + }); + + header.appendChild(btn); +} + +// ===== HTML-to-Markdown Converter ===== +function pageToMarkdown(article) { + const lines = []; + const children = article.children; + + for (const node of children) { + convertNode(node, lines); + } + + return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; +} + +function convertNode(el, lines) { + const tag = el.tagName; + + // Skip non-content elements + if (el.classList.contains('copy-page-btn')) return; + if (el.classList.contains('copy-actions')) return; + + // Page header block + if (el.classList.contains('page-header')) { + const h1 = el.querySelector('h1'); + const subtitle = el.querySelector('.page-subtitle'); + if (h1) lines.push('# ' + h1.textContent.trim(), ''); + if (subtitle) lines.push('> ' + subtitle.textContent.trim(), ''); + return; + } + + // Headings + if (/^H[1-6]$/.test(tag)) { + const level = parseInt(tag[1]); + lines.push('', '#'.repeat(level) + ' ' + el.textContent.trim(), ''); + return; + } + + // Callouts + if (el.classList.contains('callout')) { + const content = el.querySelector('.callout-content'); + if (content) { + lines.push('', '> ' + inlineText(content), ''); + } + return; + } + + // Code blocks + if (el.classList.contains('code-block')) { + const code = el.querySelector('code'); + const lang = el.dataset.lang || ''; + const mdLang = el.hasAttribute('data-md-lang') ? el.dataset.mdLang : lang; + lines.push('', '```' + mdLang); + lines.push(code.textContent); + lines.push('```', ''); + return; + } + + // Diagrams + if (el.classList.contains('diagram-container')) { + const pre = el.querySelector('pre'); + if (pre) { + lines.push('', '```'); + lines.push(pre.textContent); + lines.push('```', ''); + } + return; + } + + // Tables + if (tag === 'TABLE') { + lines.push(''); + const rows = el.querySelectorAll('tr'); + rows.forEach((row, i) => { + const cells = row.querySelectorAll('th, td'); + const cellTexts = Array.from(cells).map(c => inlineText(c)); + lines.push('| ' + cellTexts.join(' | ') + ' |'); + if (i === 0) { + lines.push('| ' + cellTexts.map(() => '---').join(' | ') + ' |'); + } + }); + lines.push(''); + return; + } + + // Paragraphs + if (tag === 'P') { + lines.push(inlineText(el), ''); + return; + } + + // Unordered lists + if (tag === 'UL') { + const items = el.querySelectorAll(':scope > li'); + items.forEach(li => { + lines.push('- ' + inlineText(li)); + }); + lines.push(''); + return; + } + + // Ordered lists + if (tag === 'OL') { + const items = el.querySelectorAll(':scope > li'); + items.forEach((li, i) => { + lines.push((i + 1) + '. ' + inlineText(li)); + }); + lines.push(''); + return; + } + + // Feature grid + if (el.classList.contains('feature-grid')) { + const cards = el.querySelectorAll('.feature-card'); + cards.forEach(card => { + const h3 = card.querySelector('h3'); + const p = card.querySelector('p'); + if (h3) lines.push('### ' + h3.textContent.trim()); + if (p) lines.push(p.textContent.trim(), ''); + }); + return; + } + + // Two-column layout + if (el.classList.contains('two-col')) { + for (const child of el.children) { + for (const inner of child.children) { + convertNode(inner, lines); + } + } + return; + } + + // Phase cards + if (el.classList.contains('phase-card')) { + const header = el.querySelector('.phase-header'); + if (header) lines.push('', '**' + header.textContent.trim() + '**'); + const ul = el.querySelector('ul'); + if (ul) convertNode(ul, lines); + return; + } + + // Accordion / details + if (tag === 'DETAILS') { + const summary = el.querySelector('summary'); + const content = el.querySelector('.accordion-content'); + if (summary) lines.push('', '**' + summary.textContent.trim() + '**'); + if (content) { + for (const child of content.children) { + convertNode(child, lines); + } + } + return; + } + + // Generic container — recurse (div, section, nav, etc.) + if (el.children && el.children.length > 0) { + for (const child of el.children) { + convertNode(child, lines); + } + return; + } +} + +function inlineText(el) { + let result = ''; + for (const node of el.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + result += node.textContent; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const tag = node.tagName; + // Skip copy buttons + if (node.classList.contains('copy-actions') || node.classList.contains('copy-btn') || node.classList.contains('copy-page-btn')) continue; + + if (tag === 'STRONG' || tag === 'B') { + result += '**' + inlineText(node) + '**'; + } else if (tag === 'EM' || tag === 'I') { + result += '*' + inlineText(node) + '*'; + } else if (tag === 'CODE') { + result += '`' + node.textContent + '`'; + } else if (tag === 'A') { + const href = node.getAttribute('href') || ''; + result += '[' + inlineText(node) + '](' + href + ')'; + } else if (tag === 'SPAN' && node.classList.contains('badge')) { + result += node.textContent.trim(); + } else if (tag === 'SPAN' && node.classList.contains('direction')) { + result += ' ' + node.textContent.trim(); + } else { + result += inlineText(node); + } + } + } + return result.replace(/\s+/g, ' ').trim(); +} + +// ===== Event Listeners ===== + +// Nav items +document.querySelectorAll('.nav-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + navigateTo(item.dataset.page); + history.pushState(null, '', '#' + item.dataset.page); + }); +}); + +// Footer nav +document.getElementById('footerPrev').addEventListener('click', (e) => { + e.preventDefault(); + if (currentPageIndex > 0) { + const prev = pages[currentPageIndex - 1]; + navigateTo(prev.id); + history.pushState(null, '', '#' + prev.id); + } +}); + +document.getElementById('footerNext').addEventListener('click', (e) => { + e.preventDefault(); + if (currentPageIndex < pages.length - 1) { + const next = pages[currentPageIndex + 1]; + navigateTo(next.id); + history.pushState(null, '', '#' + next.id); + } +}); + +// In-page anchor links (delegated) +document.addEventListener('click', (e) => { + const link = e.target.closest('a[href^="#"]'); + if (link && !link.classList.contains('nav-item') && !link.classList.contains('footer-prev') && !link.classList.contains('footer-next')) { + const target = link.getAttribute('href').slice(1); + const page = pages.find(p => p.id === target); + if (page) { + e.preventDefault(); + navigateTo(target); + history.pushState(null, '', '#' + target); + } + } +}); + +// Hash change (browser back/forward) +window.addEventListener('hashchange', () => { + const hash = window.location.hash.slice(1) || 'overview'; + navigateTo(hash); +}); + +// ===== Mobile Sidebar ===== +const sidebarToggle = document.getElementById('sidebarToggle'); +const sidebar = document.getElementById('sidebar'); +const sidebarOverlay = document.getElementById('sidebarOverlay'); + +function openSidebar() { + sidebar.classList.add('open'); + sidebarOverlay.classList.add('visible'); +} + +function closeSidebar() { + sidebar.classList.remove('open'); + sidebarOverlay.classList.remove('visible'); +} + +sidebarToggle.addEventListener('click', () => { + sidebar.classList.contains('open') ? closeSidebar() : openSidebar(); +}); + +sidebarOverlay.addEventListener('click', closeSidebar); + +// ===== Search ===== +const searchBar = document.getElementById('searchBar'); +const searchModal = document.getElementById('searchModal'); +const searchModalInput = document.getElementById('searchModalInput'); +const searchResults = document.getElementById('searchResults'); + +function openSearch() { + searchModal.style.display = 'flex'; + searchModalInput.value = ''; + searchModalInput.focus(); + renderSearchResults(''); +} + +function closeSearch() { + searchModal.style.display = 'none'; +} + +function renderSearchResults(query) { + if (!query.trim()) { + searchResults.innerHTML = pages.map(p => + ` +
${p.section}
+
${p.title}
+
` + ).join(''); + return; + } + + const q = query.toLowerCase().trim(); + const results = pages.filter(p => + p.title.toLowerCase().includes(q) || (searchIndex[p.id] && searchIndex[p.id].includes(q)) + ); + + if (results.length === 0) { + searchResults.innerHTML = '
No results found
'; + return; + } + + searchResults.innerHTML = results.map(p => + ` +
${p.section}
+
${p.title}
+
` + ).join(''); +} + +searchBar.addEventListener('click', openSearch); + +searchModal.querySelector('.search-modal-overlay').addEventListener('click', closeSearch); + +searchModalInput.addEventListener('input', (e) => { + renderSearchResults(e.target.value); +}); + +searchResults.addEventListener('click', (e) => { + const item = e.target.closest('.search-result-item'); + if (item) { + e.preventDefault(); + const pageId = item.dataset.page; + closeSearch(); + navigateTo(pageId); + history.pushState(null, '', '#' + pageId); + } +}); + +// Keyboard shortcuts +document.addEventListener('keydown', (e) => { + // Ctrl+K / Cmd+K to toggle search + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + searchModal.style.display === 'flex' ? closeSearch() : openSearch(); + } + + // Escape to close search + if (e.key === 'Escape' && searchModal.style.display === 'flex') { + closeSearch(); + } + + // Arrow nav in search results + if (searchModal.style.display === 'flex') { + const items = searchResults.querySelectorAll('.search-result-item'); + const active = searchResults.querySelector('.search-result-item.active'); + let idx = Array.from(items).indexOf(active); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (active) active.classList.remove('active'); + idx = (idx + 1) % items.length; + items[idx].classList.add('active'); + items[idx].scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (active) active.classList.remove('active'); + idx = idx <= 0 ? items.length - 1 : idx - 1; + items[idx].classList.add('active'); + items[idx].scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter') { + if (active) { + e.preventDefault(); + const pageId = active.dataset.page; + closeSearch(); + navigateTo(pageId); + history.pushState(null, '', '#' + pageId); + } + } + } +}); + +// ===== Init ===== +(async () => { + const initialHash = window.location.hash.slice(1) || 'overview'; + await navigateTo(initialHash); + // Pre-fetch remaining pages in the background for search + buildSearchIndex(); +})(); diff --git a/docs-site/assets/logo.svg b/docs-site/assets/logo.svg new file mode 100644 index 0000000..398cc6a --- /dev/null +++ b/docs-site/assets/logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs-site/index.html b/docs-site/index.html new file mode 100644 index 0000000..8cebcdd --- /dev/null +++ b/docs-site/index.html @@ -0,0 +1,119 @@ + + + + + + ReCursor Documentation + + + + + + + + +
+
+ +
+ + GitHub +
+
+
+ +
+ + + + + + + +
+
+ +
+
+
+
+ + + +
+
+ + + + + + + diff --git a/docs-site/pages/agent-sdk.html b/docs-site/pages/agent-sdk.html new file mode 100644 index 0000000..516560c --- /dev/null +++ b/docs-site/pages/agent-sdk.html @@ -0,0 +1,111 @@ +
+ + +

When to Use

+ + + + + + + + +
ScenarioSolution
Chat with agent from mobileAgent SDK session
Approve tool calls from mobileHooks + Agent SDK
See what Claude Code is doingHooks
Control existing Claude Code sessionNot supported
+ +

Setup

+
+
Terminal
+
npm install @anthropic-ai/claude-agent-sdk
+
+ +

Basic Session

+
+
TypeScript
+
import { Agent } from '@anthropic-ai/claude-agent-sdk';
+import { ReadTool, EditTool, BashTool } from '@anthropic-ai/claude-agent-sdk/tools';
+
+const agent = new Agent({
+  model: 'claude-3-5-sonnet-20241022',
+  tools: [new ReadTool(), new EditTool(), new BashTool()],
+  workingDirectory: '/home/user/project',
+});
+
+const response = await agent.run({
+  messages: [{ role: 'user', content: 'Refactor the auth module' }],
+});
+
+ +

Bridge Integration

+
+
TypeScript
+
class AgentSessionManager {
+  private sessions: Map<string, Agent> = new Map();
+
+  async createSession(sessionId: string, config: SessionConfig) {
+    const agent = new Agent({
+      model: config.model || 'claude-3-5-sonnet-20241022',
+      tools: this.loadTools(config.toolAllowlist),
+      workingDirectory: config.workingDirectory,
+    });
+
+    this.sessions.set(sessionId, agent);
+
+    agent.on('tool_use', (event) => {
+      this.eventEmitter.emit('tool-use', { sessionId, event });
+    });
+  }
+
+  async sendMessage(sessionId: string, message: string) {
+    const agent = this.sessions.get(sessionId);
+    const stream = agent.run({
+      messages: [{ role: 'user', content: message }],
+    });
+    for await (const chunk of stream) {
+      this.eventEmitter.emit('stream_chunk', { sessionId, chunk });
+    }
+  }
+}
+
+ +

Tool Configuration

+
+
TypeScript
+
import {
+  ReadTool, EditTool, BashTool,
+  GlobTool, GrepTool, LSTool,
+} from '@anthropic-ai/claude-agent-sdk/tools';
+
+const tools = [
+  new ReadTool(),      // Read file contents
+  new EditTool(),      // Edit files (find/replace)
+  new BashTool({       // Execute shell commands
+    allowedCommands: ['git', 'flutter', 'npm'],
+  }),
+  new GlobTool(),      // File globbing
+  new GrepTool(),      // Text search
+  new LSTool(),        // List directory contents
+];
+
+ +

Error Handling

+ + + + + + + + +
ErrorCauseSolution
APIErrorInvalid API key or rate limitCheck API key, implement backoff
ToolErrorTool execution failedShow error in tool card
TimeoutErrorTool took too longSet appropriate timeouts
SessionErrorSession ID not foundValidate session on mobile
+ +

Security

+
    +
  • API Key Management — Store ANTHROPIC_API_KEY in bridge server environment only. Never expose to mobile app.
  • +
  • Tool Restrictions — Use allowlists for permitted commands and blocklists for dangerous operations.
  • +
  • Working Directory Isolation — Verify working directories are within allowed paths.
  • +
+
diff --git a/docs-site/pages/architecture.html b/docs-site/pages/architecture.html new file mode 100644 index 0000000..521ab88 --- /dev/null +++ b/docs-site/pages/architecture.html @@ -0,0 +1,143 @@ +
+ + +

System Context

+
+
+  +---------------------------+
+  |   ReCursor Flutter App    |
+  |  +---------------------+  |
+  |  | OpenCode-like UI    |  |
+  |  | Riverpod State Mgmt |  |
+  |  | WebSocket Client    |  |
+  |  +----------+----------+  |
+  +-------------|-------------+
+                | wss:// (Tailscale/WireGuard)
+  +-------------|-----------------------------+
+  |  Development Machine                      |
+  |  +--------v----------+                    |
+  |  | Bridge Server     |                    |
+  |  | (TypeScript/Node)  |                    |
+  |  +--+----------+-----+                    |
+  |     |          |                           |
+  |  +--v---+  +---v-----------+              |
+  |  | Hooks|  | Agent SDK     |              |
+  |  +--+---+  | (Parallel)    |              |
+  |     |      +-------+-------+              |
+  |  +--v-----------+  |                      |
+  |  | Claude Code  |  +---> Anthropic API    |
+  |  | CLI          |                          |
+  |  +--------------+                          |
+  +--------------------------------------------+
+
+ +

Key Architectural Decisions

+ +

1. Claude Code Integration Strategy

+

Selected Architecture: Hybrid approach using Hooks for event observation + Agent SDK for parallel sessions.

+ + + + + + + + +
ApproachStatusNotes
Direct Remote Control ProtocolNot AvailableFirst-party only. No public API for third-party clients.
Claude Code HooksSupportedHTTP-based event observation (one-way)
Agent SDKSupportedParallel agent sessions (not mirroring)
MCPSupportedTool interoperability
+ +

2. UI/UX Pattern

+

ReCursor adopts OpenCode's UI patterns for the mobile interface:

+
    +
  • Tool Cards — Rich, interactive cards for tool use and results
  • +
  • Diff Viewer — Syntax-highlighted unified/side-by-side diffs
  • +
  • Session Timeline — Visual timeline of agent actions and decisions
  • +
  • Chat Interface — Streaming text with markdown rendering
  • +
+ +

3. Communication Pattern

+
+
Communication Flow
+
Mobile App <--WebSocket--> Bridge Server <--HTTP--> Claude Code Hooks
+     |                                              |
+     |                                              v
+     +------------------------------------------Observes Events
+
+
    +
  • WebSocket — Bidirectional, real-time communication between mobile and bridge
  • +
  • HTTP Hooks — One-way event streaming from Claude Code to bridge
  • +
  • No Direct Mobile-to-Claude-Code — All communication flows through the bridge
  • +
+ +

Component Responsibilities

+

ReCursor Flutter App

+ + + + + + + + +
ComponentResponsibility
UI LayerRender OpenCode-style tool cards, diff viewer, timeline
State ManagementRiverpod providers for sessions, messages, connection
WebSocket ClientConnect to bridge, handle reconnections, heartbeat
Local StorageDrift for persistence, Hive for caching
+ +

Bridge Server

+ + + + + + + + +
ComponentResponsibility
WebSocket ServerAccept mobile connections, manage sessions
Event QueueBuffer events from Hooks, replay on reconnect
HTTP EndpointReceive events from Claude Code Hooks
Agent SDK AdapterOptional parallel session management
+ +

Security Model

+
+
+  Mobile  --->  WireGuard/Tailscale  --->  TLS 1.3  --->  Device Token  --->  Bridge
+           (Network Layer)          (Transport)       (Application)
+
+
    +
  1. Network Layer: Tailscale/WireGuard mesh VPN
  2. +
  3. Transport Layer: WSS (WebSocket Secure) with TLS 1.3
  4. +
  5. Application Layer: Device pairing token on WebSocket handshake (no user accounts)
  6. +
+ +

Data Flow Summary

+
+
+

Outbound (Mobile to Agent)

+
    +
  1. User sends message from mobile app
  2. +
  3. Message queued locally (if offline)
  4. +
  5. WebSocket transmits to bridge
  6. +
  7. Bridge forwards to Agent SDK session
  8. +
  9. Agent SDK calls Claude API
  10. +
+
+
+

Inbound (Agent to Mobile)

+
    +
  1. Claude Code executes tool/action
  2. +
  3. Hooks POST event to bridge
  4. +
  5. Bridge queues event (if mobile disconnected)
  6. +
  7. WebSocket transmits to mobile
  8. +
  9. UI renders OpenCode-style component
  10. +
+
+
+ +

Limitations & Constraints

+ + + + + + + +
ConstraintImpactMitigation
Hooks are one-wayCannot inject messages into Claude CodeUse Agent SDK for parallel session
No session mirroringMobile sees events but not full contextHooks include rich event metadata
Requires local Claude CodeCannot work without desktop agentClear user messaging, offline queue
+
diff --git a/docs-site/pages/bridge-protocol.html b/docs-site/pages/bridge-protocol.html new file mode 100644 index 0000000..f25a877 --- /dev/null +++ b/docs-site/pages/bridge-protocol.html @@ -0,0 +1,208 @@ +
+ + +

Connection Lifecycle

+
+
+  ReCursor App        Bridge Server        Claude Code Hooks     Claude Code
+      |                    |                      |                   |
+      |--- wss:// + token ->                      |                   |
+      |<-- connection_ack --|                      |                   |
+      |                    |                      |                   |
+      |-- heartbeat_ping ->|                      |                   |
+      |<-- heartbeat_pong --|                      |                   |
+      |                    |                      |<-- SessionStart --|
+      |                    |<-- HTTP POST ---------|                   |
+      |<-- session_started -|                      |                   |
+
+ +

Message Format

+

All messages are JSON objects with a type field and optional id for request-response correlation.

+
+
JSON
+
{
+  "type": "message_type",
+  "id": "unique-msg-id",
+  "timestamp": "2026-03-16T10:32:00Z",
+  "payload": { ... }
+}
+
+ +

Message Types

+ +

Connection

+ +

auth (client → server)

+

Sent immediately after WebSocket opens for device pairing authentication.

+
+
JSON
+
{
+  "type": "auth",
+  "id": "auth-001",
+  "payload": {
+    "token": "device-pairing-token-xxxxx",
+    "client_version": "1.0.0",
+    "platform": "ios"
+  }
+}
+
+ +

connection_ack (server → client)

+

Confirms authentication and connection.

+
+
JSON
+
{
+  "type": "connection_ack",
+  "id": "auth-001",
+  "payload": {
+    "server_version": "1.0.0",
+    "supported_agents": ["claude-code", "opencode", "aider", "goose"],
+    "active_sessions": [
+      { "session_id": "sess-abc", "agent": "claude-code", "title": "Bridge startup validation" }
+    ]
+  }
+}
+
+ +

heartbeat_ping / heartbeat_pong

+

Keep-alive messages. Client sends ping, server responds with pong. Interval: 15 seconds. If no pong within 10 seconds, client triggers reconnect.

+ +

Agent Sessions

+

session_start (client → server)

+
+
JSON
+
{
+  "type": "session_start",
+  "id": "req-001",
+  "payload": {
+    "agent": "claude-code",
+    "session_id": null,
+    "working_directory": "/home/user/project",
+    "resume": false
+  }
+}
+
+ +

session_ready (server → client)

+
+
JSON
+
{
+  "type": "session_ready",
+  "id": "req-001",
+  "payload": {
+    "session_id": "sess-abc123",
+    "agent": "claude-code",
+    "working_directory": "/home/user/project",
+    "branch": "main",
+    "status": "ready"
+  }
+}
+
+ +

Chat Messages

+

message (client → server)

+
+
JSON
+
{
+  "type": "message",
+  "id": "msg-001",
+  "payload": {
+    "session_id": "sess-abc123",
+    "content": "Tighten the bridge startup validation",
+    "role": "user"
+  }
+}
+
+ +

stream_chunk (server → client)

+
+
JSON
+
{
+  "type": "stream_chunk",
+  "payload": {
+    "session_id": "sess-abc123",
+    "message_id": "msg-resp-001",
+    "content": "I'll tighten the bridge startup validation",
+    "is_tool_use": false
+  }
+}
+
+ +

Tool Calls

+

tool_call (server → client)

+
+
JSON
+
{
+  "type": "tool_call",
+  "id": "tool-001",
+  "payload": {
+    "session_id": "sess-abc123",
+    "tool_call_id": "call-abc123",
+    "tool": "edit_file",
+    "params": {
+      "file_path": "/home/user/project/lib/main.dart",
+      "old_string": "return wsAllowed(url);",
+      "new_string": "return requireWss(url);"
+    },
+    "description": "Require secure bridge URLs"
+  }
+}
+
+ +

approval_required (server → client)

+
+
JSON
+
{
+  "type": "approval_required",
+  "id": "tool-001",
+  "payload": {
+    "session_id": "sess-abc123",
+    "tool_call_id": "call-abc123",
+    "tool": "run_command",
+    "params": { "command": "flutter build apk" },
+    "description": "Build Android APK",
+    "risk_level": "medium",
+    "source": "hooks"
+  }
+}
+
+ +

approval_response (client → server)

+
+
JSON
+
{
+  "type": "approval_response",
+  "id": "tool-001",
+  "payload": {
+    "session_id": "sess-abc123",
+    "tool_call_id": "call-abc123",
+    "decision": "approved"
+  }
+}
+
+ +

Error Codes

+ + + + + + + + + + +
CodeDescriptionRecoverable
AUTH_FAILEDInvalid or expired tokenNo
SESSION_NOT_FOUNDSession ID doesn't existNo
AGENT_ERRORAgent execution failedYes
TOOL_ERRORTool execution failedYes
GIT_ERRORGit operation failedYes
RATE_LIMITEDToo many requestsYes
+ +

Reconnection Behavior

+
    +
  1. Client sends auth message
  2. +
  3. Server responds with connection_ack including active_sessions
  4. +
  5. Server replays any queued events (notifications, tool results)
  6. +
  7. Client acknowledges with notification_ack
  8. +
+
diff --git a/docs-site/pages/data-flow.html b/docs-site/pages/data-flow.html new file mode 100644 index 0000000..d3a7c25 --- /dev/null +++ b/docs-site/pages/data-flow.html @@ -0,0 +1,67 @@ +
+ + +

User Sends a Message

+
+
+  ReCursor App       Bridge Server       Agent SDK         Claude API
+      |                    |                 |                  |
+      |--- message ------->|                 |                  |
+      |                    |-- agent.run() ->|                  |
+      |                    |                 |--- API request ->|
+      |                    |                 |<-- stream -------|
+      |                    |<- stream_chunk -|                  |
+      |<-- stream_chunk ---|                 |                  |
+      |    (update UI)     |                 |                  |
+      |                    |<- complete -----|                  |
+      |<-- stream_end -----|                 |                  |
+
+ +

Tool Execution with Approval

+
+
+  ReCursor App       Bridge Server       Agent SDK         Tools
+      |                    |                 |               |
+      |                    |                 |-- tool_use -->|
+      |                    |<- tool_use -----|               |
+      |<-- approval_req ---|                 |               |
+      |                    |                 |               |
+      |-- approved ------->|                 |               |
+      |                    |-- continue ---->|               |
+      |                    |                 |-- execute --->|
+      |                    |                 |<-- result ----|
+      |                    |<- tool_result --|               |
+      |<-- tool_result ----|                 |               |
+      |  (render card)     |                 |               |
+
+ +

Hook Event Observation

+
+
+  ReCursor App       Bridge Server        Hooks          Claude Code
+      |                    |                 |               |
+      |                    |                 |<- PostToolUse-|
+      |                    |<- HTTP POST ----|               |
+      |                    |--- 200 OK ----->|               |
+      |<-- claude_event ---|                 |               |
+      |  (update timeline) |                 |               |
+
+ +

Reconnection & Event Replay

+
+
+  ReCursor App       Bridge Server       Event Queue
+      |                    |                 |
+      |  (disconnected)    |                 |
+      |                    |<- events -------|  (queued while offline)
+      |                    |                 |
+      |--- auth ---------> |                 |
+      |<-- connection_ack -|                 |
+      |<-- queued events --|-- dequeue ----->|
+      |-- notification_ack>|                 |
+
+
diff --git a/docs-site/pages/hooks.html b/docs-site/pages/hooks.html new file mode 100644 index 0000000..9e259d7 --- /dev/null +++ b/docs-site/pages/hooks.html @@ -0,0 +1,150 @@ +
+ + +
+
+ +
+
+ Hooks are one-way observation only. They cannot inject messages or control the Claude Code session. For bidirectional communication, use the Agent SDK. +
+
+ +

Supported Hook Events

+ + + + + + + + + + + + +
EventTriggerPayload
SessionStartNew Claude Code session beginsSession metadata
SessionEndSession terminatesSession summary
PreToolUseAgent about to use a toolTool, params, risk level
PostToolUseTool execution completedTool, result, metadata
UserPromptSubmitUser submits a promptPrompt text, context
StopAgent stops executionStop reason, context
SubagentStopSubagent stops executionSubagent result, context
NotificationSystem notificationMessage, level
+ +

Hook Configuration

+

Hooks are configured via hooks.json in your Claude Code plugin directory. Two types are supported:

+
    +
  • type: "command" — Execute bash commands for deterministic checks
  • +
  • type: "prompt" — Use LLM-driven decision making
  • +
+ +

Command Hooks (Recommended)

+
+
hooks.json
+
{
+  "description": "ReCursor bridge integration",
+  "hooks": {
+    "PreToolUse": [{
+      "hooks": [{
+        "type": "command",
+        "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer $BRIDGE_TOKEN' -d @-",
+        "timeout": 10
+      }]
+    }],
+    "PostToolUse": [{
+      "hooks": [{
+        "type": "command",
+        "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer $BRIDGE_TOKEN' -d @-",
+        "timeout": 10
+      }]
+    }],
+    "SessionStart": [{
+      "hooks": [{
+        "type": "command",
+        "command": "curl -X POST https://100.78.42.15:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer $BRIDGE_TOKEN' -d @-",
+        "timeout": 10
+      }]
+    }]
+  }
+}
+
+ +

Plugin Directory Structure

+
+
File Tree
+
~/.claude-code/plugins/
+  recursor-bridge/
+    hooks.json          # Hook definitions
+    README.md           # Plugin documentation
+
+ +

Event Payloads

+ +

PostToolUse

+
+
JSON
+
{
+  "event_type": "PostToolUse",
+  "session_id": "sess-abc123",
+  "timestamp": "2026-03-17T10:32:00Z",
+  "payload": {
+    "tool": "edit_file",
+    "params": {
+      "file_path": "/home/user/project/lib/main.dart",
+      "old_string": "void main() {",
+      "new_string": "void main() async {"
+    },
+    "result": { "success": true, "diff": "..." },
+    "metadata": { "token_count": 150, "duration_ms": 250 }
+  }
+}
+
+ +

PreToolUse

+
+
JSON
+
{
+  "event_type": "PreToolUse",
+  "session_id": "sess-abc123",
+  "timestamp": "2026-03-17T10:32:00Z",
+  "payload": {
+    "tool": "Bash",
+    "params": { "command": "rm -rf /important" },
+    "risk_level": "high",
+    "requires_approval": true
+  }
+}
+
+ +

Security Considerations

+
    +
  1. Token Authentication — Always use Authorization: Bearer <token>
  2. +
  3. HTTPS Only — Use TLS for all hook communications
  4. +
  5. Payload Validation — Validate all incoming hook events before processing
  6. +
  7. Rate Limiting — Implement rate limiting on the /hooks/event endpoint
  8. +
+ +

Troubleshooting

+
+
+ Hooks not firing +
+
    +
  1. Verify hooks.json syntax is valid JSON
  2. +
  3. Check plugin is in correct directory: ~/.claude-code/plugins/
  4. +
  5. Ensure hook commands have execute permissions
  6. +
  7. Review Claude Code logs for hook execution errors
  8. +
+
+
+
+ Bridge not receiving events +
+
    +
  1. Verify bridge URL is accessible from Claude Code host
  2. +
  3. Check firewall rules allow outbound HTTP to bridge
  4. +
  5. Confirm authentication token matches
  6. +
  7. Test with simple curl command manually
  8. +
+
+
+
+
diff --git a/docs-site/pages/offline.html b/docs-site/pages/offline.html new file mode 100644 index 0000000..b473c30 --- /dev/null +++ b/docs-site/pages/offline.html @@ -0,0 +1,91 @@ +
+ + +

Storage Strategy

+ + + + + + + +
Data TypeStorageRationale
Conversations, tasks, agent configsDrift (SQLite)Type-safe queries, migrations, reactive streams
UI preferences, cached tokens, session stateHiveFast key-value for ephemeral data
File content cacheFile systemLarge blobs don't belong in SQLite
+ +

Repository Pattern

+
+
Architecture
+
UI Layer (Riverpod providers)
+    |
+Repository Layer (abstracts local vs. remote)
+    |
+    +-- Local Data Source (Drift / Hive)
+    +-- Remote Data Source (Bridge WebSocket)
+
+

Repository reads from local DB first (instant UI response), fetches from bridge in background, and Drift's reactive queries automatically update the UI when local data changes.

+ +

Sync Queue

+

When offline, mutations go into a local queue:

+
+
Dart
+
class SyncQueue extends Table {
+  IntColumn get id => integer().autoIncrement()();
+  TextColumn get operation => text()();     // "send_message", "approve_tool"
+  TextColumn get payload => text()();       // JSON: full operation
+  TextColumn get sessionId => text().nullable()();
+  DateTimeColumn get createdAt => dateTime()();
+  BoolColumn get synced => boolean().withDefault(const Constant(false))();
+  IntColumn get retryCount => integer().withDefault(const Constant(0))();
+}
+
+ +

Connection States

+ + + + + + + +
StateDescriptionBehavior
onlineConnected to bridgeSync queue, real-time updates
offlineNo connectivityQueue mutations locally
bridge_unreachableNetwork but no bridgeRetry with backoff, queue mutations
+ +

Sync Strategies

+
+
+

Push-First (Outbound)

+
    +
  1. User action (send message, approve tool)
  2. +
  3. Save to local DB
  4. +
  5. Try to send via WebSocket
  6. +
  7. If failed, add to SyncQueue
  8. +
  9. Show "pending" state in UI
  10. +
+
+
+

Pull-First (Inbound)

+
    +
  1. On reconnect, request events since last sync
  2. +
  3. Merge with local state
  4. +
  5. Resolve conflicts
  6. +
  7. Update UI
  8. +
+
+
+ +

Conflict Resolution

+

Default: Last-write-wins using updated_at timestamps. For destructive operations (git push, file overwrite), the user is prompted with a conflict dialog.

+ +

Storage Limits

+ + + + + + + + +
Data TypeMax SizeCleanup Strategy
SyncQueue1000 itemsFIFO eviction
Messages30 daysArchive to file
Sessions90 daysSoft delete
File cache100 MBLRU eviction
+
diff --git a/docs-site/pages/overview.html b/docs-site/pages/overview.html new file mode 100644 index 0000000..dad3260 --- /dev/null +++ b/docs-site/pages/overview.html @@ -0,0 +1,115 @@ +
+ + +
+
+ +
+
+ Status: Architecture specification complete. Implementation pending. +
+
+ +

ReCursor enables developers to leverage AI-powered coding agents from anywhere, without requiring a desktop environment. Full agentic coding workflows on mobile — plan, code, test, and deploy applications using only your phone or tablet.

+ +

The UI/UX mirrors OpenCode (terminal-native AI coding agent) with its rich tool cards, diff viewer, and session timeline, while the underlying events come from Claude Code running on your development machine via supported integration mechanisms.

+ +

Why ReCursor?

+
+
+
📱
+

Coding On-the-Go

+

Review code, approve changes, and chat with agents from anywhere on your mobile device.

+
+
+
🚀
+

Remote Productivity

+

No laptop required for code review, simple fixes, and agent interactions.

+
+
+
🤖
+

AI-First Workflow

+

Voice commands to AI agents while away from your desk. Full agent control from mobile.

+
+
+
🔄
+

Continuous Context

+

Start coding at your desk, continue from your phone. Seamless session handoff.

+
+
+ +

How It Works

+
+
+  +-----------------------+        +-------------------------------+
+  |  Mobile Device        |        |  Development Machine          |
+  |                       |        |                               |
+  |  +--ReCursor App---+  |        |  +--Bridge Server----------+  |
+  |  | OpenCode-style  |  |  WSS   |  | WebSocket + HTTP        |  |
+  |  | UI (Tool Cards, |<-------->|  | Event Queue             |  |
+  |  | Diff Viewer,    |  | Tunnel |  | Agent SDK Adapter       |  |
+  |  | Timeline)       |  |        |  +-----------+-------------+  |
+  |  +-----------------+  |        |              |                 |
+  +-----------------------+        |   +----------v-----------+    |
+                                   |   | Claude Code Hooks    |    |
+                                   |   | (Event Observer)     |    |
+                                   |   +----------+-----------+    |
+                                   |              |                 |
+                                   |   +----------v-----------+    |
+                                   |   | Claude Code CLI      |    |
+                                   |   +----------------------+    |
+                                   +-------------------------------+
+
+ +

Integration Strategy

+ + + + + + + + + + +
ApproachStatusNotes
Direct Remote ControlNot AvailableFirst-party only (claude.ai/code, official apps)
Claude Code HooksSupportedHTTP-based event observation (one-way)
Agent SDKSupportedParallel agent sessions (not mirroring)
MCP (Model Context Protocol)SupportedTool interoperability
+ +
+
+ +
+
+ Important: Claude Code Remote Control is designed exclusively for first-party Anthropic clients. ReCursor does not claim to mirror or control existing Claude Code sessions. +
+
+ +

Core Features

+ + + + + + + + + + + + + + +
FeatureDescriptionPhase
Agent Chat InterfaceMobile chat UI with OpenCode-style tool cardsPhase 1
Tool Call ApprovalApprove/reject agent actions with rich contextPhase 1
Code Diff ViewerSyntax-highlighted diffs with OpenCode patternsPhase 2
Git OperationsCommit, push, pull, merge from mobilePhase 2
Session TimelineVisual timeline of agent actions and decisionsPhase 2
Push NotificationsReal-time alerts for agent events via WebSocketPhase 2
Voice CommandsSpeech-to-code capabilitiesPhase 3
Offline ModeWork without connectivity, sync on reconnectPhase 3
+ +

Next Steps

+ +
diff --git a/docs-site/pages/quickstart.html b/docs-site/pages/quickstart.html new file mode 100644 index 0000000..ac79ce3 --- /dev/null +++ b/docs-site/pages/quickstart.html @@ -0,0 +1,76 @@ +
+ + +
+
+ +
+
+ Prerequisites: Node.js 20+, Flutter 3.x, Claude Code CLI installed, and an Anthropic API key. +
+
+ +

Step 1: Start the Bridge Server

+

The bridge server runs on your development machine and relays events between Claude Code and the mobile app.

+
+
Terminal
+
cd packages/bridge
+npm install
+cp .env.example .env   # Add your ANTHROPIC_API_KEY
+npm run dev
+
+ +

Step 2: Configure Claude Code Hooks

+

Set up Claude Code to forward events to the bridge.

+
+
~/.claude-code/plugins/recursor-bridge/hooks.json
+
{
+  "description": "ReCursor bridge integration",
+  "hooks": {
+    "PostToolUse": [{
+      "hooks": [{
+        "type": "command",
+        "command": "curl -X POST https://localhost:3000/hooks/event -H 'Content-Type: application/json' -H 'Authorization: Bearer $BRIDGE_TOKEN' -d @-",
+        "timeout": 10
+      }]
+    }]
+  }
+}
+
+ +

Step 3: Install the Mobile App

+
+
Terminal
+
cd apps/mobile
+flutter pub get
+flutter run
+
+ +

Step 4: Pair Your Device

+
    +
  1. Open the ReCursor app on your mobile device
  2. +
  3. The app opens directly to bridge pairing (no login required)
  4. +
  5. Scan the QR code displayed by the bridge server
  6. +
  7. The app connects and restores any active sessions
  8. +
+ +
+
+ +
+
+ Remote access: For access outside your local network, set up Tailscale or WireGuard for secure tunneling to your bridge. +
+
+ +

What's Next?

+ +
diff --git a/docs-site/pages/roadmap.html b/docs-site/pages/roadmap.html new file mode 100644 index 0000000..e081952 --- /dev/null +++ b/docs-site/pages/roadmap.html @@ -0,0 +1,80 @@ +
+ + +

Phase 1: Foundation

+
+
+ Phase 1 + Bootable app with bridge connectivity and basic agent chat +
+
    +
  • Initialize Flutter project (iOS + Android)
  • +
  • Bridge connection with QR code pairing
  • +
  • WebSocket client with auto-reconnect
  • +
  • Basic chat interface with streaming text
  • +
  • OpenCode-style message part rendering
  • +
  • Repository file browsing
  • +
  • CI/CD pipeline setup
  • +
+
+ +

Phase 2: Core Features

+
+
+ Phase 2 + Hooks integration, tool cards, diff viewer, notifications +
+
    +
  • Claude Code Hooks event reception
  • +
  • OpenCode-style tool cards (pending/running/completed/error)
  • +
  • Syntax-highlighted diff viewer (unified + side-by-side)
  • +
  • Session timeline with event navigation
  • +
  • Tool call approval flow
  • +
  • WebSocket-based push notifications
  • +
+
+ +

Phase 3: Advanced Features

+
+
+ Phase 3 + Voice, multi-agent, terminal, offline mode +
+
    +
  • Voice commands via platform speech APIs
  • +
  • Multi-agent support (Claude Code, OpenCode, Aider, Goose)
  • +
  • Embedded terminal view with ANSI color rendering
  • +
  • Full offline mode with Drift/Hive sync queue
  • +
+
+ +

Phase 4: Polish & Release

+
+
+ Phase 4 + Performance, accessibility, app store release +
+
    +
  • Large repo handling and performance optimization
  • +
  • Tablet and landscape layouts
  • +
  • Accessibility (TalkBack, VoiceOver, dynamic type)
  • +
  • App Store and Google Play release preparation
  • +
+
+ +

Testing Strategy

+ + + + + + + + + +
LevelToolCoverage
Unitflutter_test + mockitoServices, providers, models
Widgetflutter_testUI components with mocks
GoldenalchemistVisual regression for key screens
Integrationintegration_testFull flows: connect, chat, hooks
E2EpatrolComplete user journeys on real devices
+
diff --git a/docs-site/pages/security.html b/docs-site/pages/security.html new file mode 100644 index 0000000..b135bc1 --- /dev/null +++ b/docs-site/pages/security.html @@ -0,0 +1,101 @@ +
+ + +

Network Layer

+
    +
  • Use a secure tunnel for remote access. Tailscale (recommended) wraps WireGuard encryption, handles NAT traversal, and creates a zero-config mesh VPN.
  • +
  • Always use wss:// — TLS at the application layer + tunnel encryption = defense in depth.
  • +
  • Never expose the bridge on a public IP without tunnel protection.
  • +
+ +

Token Management

+ + + + + + +
Token TypePurposeStorage
Device Pairing TokenAuthenticate mobile app to bridgeflutter_secure_storage
Hook TokenAuthenticate Claude Code Hooks to bridgeBridge server env only
+ +

Device Pairing Token

+
    +
  • Generate: 32+ character crypto-safe random string
  • +
  • Mobile storage: Encrypted with flutter_secure_storage (Keychain / EncryptedSharedPreferences)
  • +
  • QR Code: Bridge URL + token encoded for easy pairing
  • +
  • No user accounts — tokens are per-device
  • +
+ +
+
TypeScript
+
import crypto from 'crypto';
+const token = crypto.randomBytes(32).toString('hex'); // 64 chars
+
+ +

Certificate Pinning

+
+
Dart
+
Future<SecurityContext> getSecureContext() async {
+  final context = SecurityContext(withTrustedRoots: false);
+  final cert = await rootBundle.load('assets/certs/bridge.crt');
+  context.setTrustedCertificatesBytes(cert.buffer.asUint8List());
+  return context;
+}
+
+final channel = IOWebSocketChannel.connect(
+  'wss://100.78.42.15:3000',
+  customClient: HttpClient(context: await getSecureContext()),
+);
+
+ +

Bridge Authorization

+
    +
  • Allowlist of permitted operations — e.g., file read yes, rm -rf / no
  • +
  • Working directory boundaries — agent only accesses project directory
  • +
  • Audit logging — log all commands sent through the bridge
  • +
  • Separate bridge auth from agent auth
  • +
+ +
+
TypeScript
+
const ALLOWED_TOOLS = ['read_file', 'edit_file', 'glob', 'grep', 'ls'];
+const BLOCKED_COMMANDS = ['rm -rf /', 'sudo', 'chmod 777'];
+
+ +

Threat Model

+ + + + + + + + + +
ThreatLikelihoodImpactMitigation
Token theftMediumHighSecure storage, rotation, short expiry
Man-in-the-middleLowHighTLS + certificate pinning
Bridge compromiseLowCriticalWorking directory isolation, allowlist
Replay attacksLowMediumTimestamp validation, nonce
Social engineeringMediumMediumOut-of-band confirmation for destructive ops
+ +

Security Checklist

+
+
+

Development

+
    +
  • Never commit secrets to repository
  • +
  • Use .env for local config
  • +
  • Run flutter analyze security lints
  • +
  • Dependency scanning (Snyk or similar)
  • +
+
+
+

Deployment

+
    +
  • Unique bridge auth tokens per device
  • +
  • Enable TLS 1.3 on bridge server
  • +
  • Configure Tailscale ACLs
  • +
  • Enable audit logging
  • +
+
+
+
diff --git a/docs-site/pages/tech-stack.html b/docs-site/pages/tech-stack.html new file mode 100644 index 0000000..1462602 --- /dev/null +++ b/docs-site/pages/tech-stack.html @@ -0,0 +1,82 @@ +
+ + +

Mobile App

+ + + + + + + + + + + +
LayerChoiceRationale
FrameworkFlutterCross-platform, CC Pocket precedent
State ManagementRiverpodType-safe, testable, Conduit pattern
Networkingweb_socket_channelStandard Flutter WebSocket
Local DBDrift (SQLite)Type-safe queries, migrations
CacheHiveFast key-value for ephemeral data
Notificationsflutter_local_notificationsLocal alerts when backgrounded
Code GenFreezedImmutable models with unions
+ +

Bridge Server

+ + + + + + + + + + + + +
LayerChoiceRationale
RuntimeNode.js 20+CC Pocket pattern, broad ecosystem
LanguageTypeScript 5.xType safety for protocol messages
HTTPExpressMinimal, well-understood
WebSocketwsLightweight, production-ready
Agent@anthropic-ai/sdkOfficial Agent SDK
ValidationZodRuntime type checking
Gitsimple-gitGit operations from bridge
TestingJestStandard test framework
+ +

Infrastructure

+ + + + + + + + + +
LayerChoiceRationale
TunnelTailscale / WireGuardZero-config mesh VPN
CI/CDGitHub Actions + FastlaneIndustry standard
iOS SigningFastlane MatchCode signing management
Android SigningKeystoreStandard Android signing
Crash ReportingSentry (planned)Error tracking
+ +

Project Structure

+
+
File Tree
+
ReCursor/
+  apps/mobile/                  # Flutter app (iOS + Android)
+    lib/
+      core/                     # App-wide infrastructure
+        config/                 # Theme, router, app config
+        models/                 # Data models (Freezed)
+        network/                # WebSocket client
+        providers/              # Riverpod providers
+        storage/                # Drift database, Hive, secure storage
+      features/                 # Feature modules
+        startup/                # Bridge pairing and restore
+        chat/                   # Agent chat interface
+        diff/                   # Code diff viewer
+        session/                # Session management
+        git/                    # Git operations
+        approvals/              # Tool call approvals
+        terminal/               # Terminal view
+        settings/               # Settings screen
+      shared/                   # Shared UI components
+
+  packages/bridge/              # TypeScript bridge server
+    src/
+      agents/                   # Agent SDK integration
+      hooks/                    # Claude Code Hooks receiver
+      websocket/                # WebSocket server
+      git/                      # Git operations
+      auth/                     # Token validation
+
+  docs/                         # Project documentation
+
+
diff --git a/docs-site/pages/ui-patterns.html b/docs-site/pages/ui-patterns.html new file mode 100644 index 0000000..0cb57fa --- /dev/null +++ b/docs-site/pages/ui-patterns.html @@ -0,0 +1,102 @@ +
+ + +

Component Mapping

+ + + + + + + + + +
OpenCode ComponentReCursor WidgetFile
BasicToolToolCardlib/features/chat/widgets/tool_card.dart
DiffChangesDiffViewerlib/features/diff/widgets/diff_viewer.dart
SessionTurnSessionTimelinelib/features/session/widgets/session_timeline.dart
MessagePartMessagePartlib/features/chat/widgets/message_part.dart
ChatMessageMessageBubblelib/features/chat/widgets/message_bubble.dart
+ +

Tool Cards

+

OpenCode renders rich tool cards in the terminal. ReCursor adapts these as Flutter Material cards with four states:

+ + + + + + + + +
StateOpenCode (Terminal)ReCursor (Flutter)
PendingSpinner + "Running..."CircularProgressIndicator + pulse animation
RunningLive output streamStreaming text with fade-in
CompletedCheckmark + resultIcons.check_circle + expandable result
ErrorRed X + error detailsIcons.error + error card
+ +

Flutter Implementation

+
+
Dart
+
class ToolCard extends StatelessWidget {
+  final ToolUse tool;
+  final ToolResult? result;
+  final ToolStatus status;
+
+  @override
+  Widget build(BuildContext context) {
+    return Card(
+      elevation: 2,
+      margin: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          _ToolHeader(tool: tool, status: status),
+          _ToolParams(params: tool.params),
+          if (result != null) _ToolResult(result: result),
+        ],
+      ),
+    );
+  }
+}
+
+ +

Diff Viewer

+
+
Dart
+
class DiffViewer extends StatelessWidget {
+  final List<DiffFile> files;
+  final DiffViewMode viewMode;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListView.builder(
+      itemCount: files.length,
+      itemBuilder: (context, index) {
+        return DiffFileCard(file: files[index], viewMode: viewMode);
+      },
+    );
+  }
+}
+
+ +

Theming

+

ReCursor uses an OpenCode-inspired terminal color scheme adapted for Material You.

+ + + + + + + + + + +
ElementColorUsage
Background #1E1E1EApp background
Text #D4D4D4Primary text
Added lines #4EC9B0Diff additions
Removed lines #F44747Diff deletions
Tool header #569CD6Tool card headers
Accent #CE9178Highlights, links
+ +

Responsive Design

+ + + + + + + + + +
Terminal (OpenCode)Mobile (ReCursor)
Fixed-width fontDynamic font sizing
Horizontal scrollingHorizontal swipe gestures
Keyboard shortcutsTouch gestures + FABs
Split panesTab navigation
Mouse hoverLong-press menus
+
diff --git a/docs-site/styles.css b/docs-site/styles.css new file mode 100644 index 0000000..69046f8 --- /dev/null +++ b/docs-site/styles.css @@ -0,0 +1,1144 @@ +/* ===== Reset & Base ===== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-primary: #0f0f10; + --bg-secondary: #18181b; + --bg-tertiary: #1e1e22; + --bg-elevated: #252529; + --bg-hover: #2a2a2f; + + --border-subtle: #27272a; + --border-default: #3f3f46; + --border-strong: #52525b; + + --text-primary: #fafafa; + --text-secondary: #a1a1aa; + --text-tertiary: #71717a; + --text-link: #818cf8; + --text-link-hover: #a5b4fc; + + --accent-blue: #6366f1; + --accent-blue-bg: rgba(99, 102, 241, 0.1); + --accent-green: #4ade80; + --accent-green-bg: rgba(74, 222, 128, 0.08); + --accent-red: #f87171; + --accent-red-bg: rgba(248, 113, 113, 0.08); + --accent-yellow: #fbbf24; + --accent-yellow-bg: rgba(251, 191, 36, 0.08); + --accent-purple: #c084fc; + --accent-purple-bg: rgba(192, 132, 252, 0.08); + --accent-orange: #fb923c; + --accent-orange-bg: rgba(251, 146, 60, 0.08); + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + + --sidebar-width: 260px; + --header-height: 56px; + --content-max-width: 820px; + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.7; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +a { + color: var(--text-link); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--text-link-hover); +} + +/* ===== Top Navigation ===== */ +.top-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--header-height); + background: rgba(15, 15, 16, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-subtle); + z-index: 100; +} + +.top-nav-inner { + max-width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; +} + +.top-nav-left { + display: flex; + align-items: center; + gap: 12px; +} + +.top-nav-right { + display: flex; + align-items: center; + gap: 16px; +} + +.sidebar-toggle { + display: none; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: color var(--transition-fast); +} + +.sidebar-toggle:hover { + color: var(--text-primary); +} + +.logo-link { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); + font-weight: 600; + font-size: 15px; + text-decoration: none; +} + +.logo-svg { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.logo-divider { + width: 1px; + height: 16px; + background: var(--border-default); +} + +.logo-docs { + color: var(--text-secondary); + font-weight: 500; +} + +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--bg-secondary); + cursor: pointer; + transition: all var(--transition-fast); + min-width: 240px; +} + +.search-bar:hover { + border-color: var(--border-default); + background: var(--bg-tertiary); +} + +.search-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.search-bar input { + background: none; + border: none; + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 13px; + outline: none; + flex: 1; + cursor: pointer; +} + +.search-bar input::placeholder { + color: var(--text-tertiary); +} + +.search-shortcut { + font-size: 11px; + color: var(--text-tertiary); + background: var(--bg-elevated); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border-subtle); + font-family: var(--font-sans); +} + +.nav-link { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + transition: color var(--transition-fast); +} + +.nav-link:hover { + color: var(--text-primary); +} + +/* ===== Layout ===== */ +.layout { + display: flex; + min-height: 100vh; + padding-top: var(--header-height); +} + +/* ===== Sidebar ===== */ +.sidebar { + position: fixed; + top: var(--header-height); + left: 0; + width: var(--sidebar-width); + height: calc(100vh - var(--header-height)); + overflow-y: auto; + background: var(--bg-primary); + border-right: 1px solid var(--border-subtle); + padding: 16px 0; + z-index: 50; +} + +.sidebar::-webkit-scrollbar { + width: 4px; +} + +.sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 4px; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 4px; +} + +.nav-section { + padding: 0 12px; + margin-bottom: 8px; +} + +.nav-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + padding: 8px 12px 6px; + margin-top: 8px; +} + +.nav-section:first-child .nav-section-title { + margin-top: 0; +} + +.nav-item { + display: block; + padding: 6px 12px; + font-size: 13.5px; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + text-decoration: none; + font-weight: 450; +} + +.nav-item:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.nav-item.active { + color: var(--text-primary); + background: var(--accent-blue-bg); + font-weight: 500; +} + +.sidebar-overlay { + display: none; +} + +/* ===== Content ===== */ +.content { + margin-left: var(--sidebar-width); + flex: 1; + min-width: 0; + padding: 40px 48px 80px; +} + +.page { + max-width: var(--content-max-width); + margin: 0 auto; +} + +/* Page Header */ +.page-header { + position: relative; + margin-bottom: 40px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-subtle); +} + +.copy-page-btn { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-tertiary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.copy-page-btn:hover { + color: var(--text-primary); + border-color: var(--border-default); + background: var(--bg-tertiary); +} + +.copy-page-btn .copied { + color: var(--accent-green); +} + +.breadcrumb { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent-blue); + margin-bottom: 8px; +} + +.page-header h1 { + font-size: 36px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--text-primary); + margin-bottom: 12px; +} + +.page-subtitle { + font-size: 17px; + color: var(--text-secondary); + line-height: 1.6; +} + +/* Typography */ +h2 { + font-size: 24px; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--text-primary); + margin-top: 48px; + margin-bottom: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-subtle); +} + +h2:first-of-type { + border-top: none; + padding-top: 0; +} + +h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-top: 32px; + margin-bottom: 12px; +} + +h4 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-top: 24px; + margin-bottom: 8px; +} + +p { + margin-bottom: 16px; + color: var(--text-secondary); +} + +ul, ol { + margin-bottom: 16px; + padding-left: 24px; +} + +li { + margin-bottom: 6px; + color: var(--text-secondary); +} + +li strong { + color: var(--text-primary); +} + +code { + font-family: var(--font-mono); + font-size: 13px; + background: var(--bg-elevated); + padding: 2px 6px; + border-radius: 4px; + color: var(--accent-purple); + border: 1px solid var(--border-subtle); +} + +.direction { + font-size: 12px; + color: var(--text-tertiary); + font-weight: 400; + margin-left: 4px; +} + +/* ===== Code Blocks ===== */ +.code-block { + margin-bottom: 20px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-secondary); +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-subtle); + font-size: 12px; + color: var(--text-tertiary); + font-weight: 500; +} + +.copy-actions { + display: flex; + gap: 4px; +} + +.copy-btn { + background: none; + border: 1px solid var(--border-subtle); + color: var(--text-tertiary); + padding: 2px 10px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + font-family: var(--font-sans); + transition: all var(--transition-fast); + white-space: nowrap; +} + +.copy-btn:hover { + color: var(--text-primary); + border-color: var(--border-default); + background: var(--bg-hover); +} + +.copy-btn.copied { + color: var(--accent-green); + border-color: rgba(74, 222, 128, 0.3); +} + +.code-block pre { + padding: 16px; + overflow-x: auto; + margin: 0; +} + +.code-block code { + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--text-primary); + background: none; + padding: 0; + border: none; + border-radius: 0; +} + +/* ===== Tables ===== */ +.doc-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 24px; + font-size: 14px; +} + +.doc-table thead { + background: var(--bg-tertiary); +} + +.doc-table th { + text-align: left; + padding: 10px 16px; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--border-default); + font-size: 13px; +} + +.doc-table td { + padding: 10px 16px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); + vertical-align: top; +} + +.doc-table tr:last-child td { + border-bottom: none; +} + +.doc-table tr:hover td { + background: var(--bg-hover); +} + +/* ===== Badges ===== */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.badge-green { + background: var(--accent-green-bg); + color: var(--accent-green); + border: 1px solid rgba(74, 222, 128, 0.2); +} + +.badge-red { + background: var(--accent-red-bg); + color: var(--accent-red); + border: 1px solid rgba(248, 113, 113, 0.2); +} + +.badge-blue { + background: var(--accent-blue-bg); + color: #818cf8; + border: 1px solid rgba(99, 102, 241, 0.2); +} + +.badge-purple { + background: var(--accent-purple-bg); + color: var(--accent-purple); + border: 1px solid rgba(192, 132, 252, 0.2); +} + +.badge-orange { + background: var(--accent-orange-bg); + color: var(--accent-orange); + border: 1px solid rgba(251, 146, 60, 0.2); +} + +.badge-yellow { + background: var(--accent-yellow-bg); + color: var(--accent-yellow); + border: 1px solid rgba(251, 191, 36, 0.2); +} + +/* ===== Callouts ===== */ +.callout { + display: flex; + gap: 12px; + padding: 16px 20px; + border-radius: var(--radius-md); + margin-bottom: 24px; + border: 1px solid; +} + +.callout-icon { + flex-shrink: 0; + margin-top: 1px; +} + +.callout-content { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); +} + +.callout-content strong { + color: var(--text-primary); +} + +.callout-content a { + color: var(--text-link); +} + +.callout-info { + background: var(--accent-blue-bg); + border-color: rgba(99, 102, 241, 0.2); +} + +.callout-info .callout-icon { + color: var(--accent-blue); +} + +.callout-warning { + background: var(--accent-yellow-bg); + border-color: rgba(251, 191, 36, 0.2); +} + +.callout-warning .callout-icon { + color: var(--accent-yellow); +} + +.callout-tip { + background: var(--accent-green-bg); + border-color: rgba(74, 222, 128, 0.2); +} + +.callout-tip .callout-icon { + color: var(--accent-green); +} + +/* ===== Feature Grid ===== */ +.feature-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.feature-card { + padding: 24px; + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + transition: all var(--transition-normal); +} + +.feature-card:hover { + border-color: var(--border-default); + background: var(--bg-tertiary); + transform: translateY(-1px); +} + +.feature-icon { + font-size: 28px; + margin-bottom: 12px; +} + +.feature-card h3 { + font-size: 16px; + font-weight: 600; + margin-top: 0; + margin-bottom: 8px; + color: var(--text-primary); +} + +.feature-card p { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 0; + line-height: 1.5; +} + +/* ===== Diagrams ===== */ +.diagram-container { + margin-bottom: 24px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow-x: auto; + background: var(--bg-secondary); +} + +.diagram { + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.5; + color: var(--text-secondary); + padding: 20px 24px; + margin: 0; + white-space: pre; +} + +/* ===== Two Column Layout ===== */ +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 24px; +} + +/* ===== Phase Cards ===== */ +.phase-card { + padding: 24px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-bottom: 24px; + background: var(--bg-secondary); +} + +.phase-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.phase-card ul { + margin-bottom: 0; +} + +/* ===== Accordion ===== */ +.accordion { + margin-bottom: 24px; +} + +details { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + margin-bottom: 8px; + overflow: hidden; +} + +summary { + padding: 12px 16px; + cursor: pointer; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-secondary); + transition: background var(--transition-fast); + list-style: none; +} + +summary::-webkit-details-marker { + display: none; +} + +summary::before { + content: '>'; + display: inline-block; + margin-right: 8px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); + transition: transform var(--transition-fast); +} + +details[open] summary::before { + transform: rotate(90deg); +} + +summary:hover { + background: var(--bg-tertiary); +} + +.accordion-content { + padding: 16px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-primary); +} + +/* ===== Checklist ===== */ +.checklist { + list-style: none; + padding-left: 0; +} + +.checklist li { + padding-left: 24px; + position: relative; +} + +.checklist li::before { + content: ''; + position: absolute; + left: 0; + top: 7px; + width: 14px; + height: 14px; + border: 1.5px solid var(--border-default); + border-radius: 3px; +} + +/* ===== Color Swatches ===== */ +.color-swatch { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 3px; + vertical-align: middle; + margin-right: 4px; + border: 1px solid var(--border-default); +} + +/* ===== Footer ===== */ +.page-footer { + max-width: var(--content-max-width); + margin: 60px auto 0; + border-top: 1px solid var(--border-subtle); + padding-top: 24px; +} + +.footer-nav { + display: flex; + justify-content: space-between; + gap: 24px; + margin-bottom: 32px; +} + +.footer-prev, .footer-next { + display: flex; + flex-direction: column; + padding: 16px 20px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + text-decoration: none; + transition: all var(--transition-fast); + min-width: 200px; +} + +.footer-next { + text-align: right; + margin-left: auto; +} + +.footer-prev:hover, .footer-next:hover { + border-color: var(--border-default); + background: var(--bg-secondary); +} + +.footer-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + margin-bottom: 4px; +} + +.footer-title { + font-size: 15px; + font-weight: 500; + color: var(--text-link); +} + +.footer-meta { + text-align: center; + padding: 16px 0; +} + +.footer-meta p { + font-size: 12px; + color: var(--text-tertiary); +} + +/* ===== Search Modal ===== */ +.search-modal { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 120px; +} + +.search-modal-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.search-modal-content { + position: relative; + width: 560px; + max-height: 400px; + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.search-modal-input-wrap { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-tertiary); +} + +.search-modal-input-wrap input { + flex: 1; + background: none; + border: none; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 16px; + outline: none; +} + +.search-modal-input-wrap input::placeholder { + color: var(--text-tertiary); +} + +.search-modal-input-wrap kbd { + font-size: 11px; + color: var(--text-tertiary); + background: var(--bg-elevated); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--border-subtle); + font-family: var(--font-sans); +} + +.search-results { + max-height: 320px; + overflow-y: auto; + padding: 8px; +} + +.search-result-item { + display: block; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + text-decoration: none; + transition: background var(--transition-fast); +} + +.search-result-item:hover, +.search-result-item.active { + background: var(--bg-hover); +} + +.search-result-section { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.search-result-title { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.search-no-results { + padding: 24px; + text-align: center; + color: var(--text-tertiary); + font-size: 14px; +} + +/* ===== Responsive ===== */ +@media (max-width: 1024px) { + .content { + padding: 32px 32px 60px; + } +} + +@media (max-width: 768px) { + .sidebar-toggle { + display: block; + } + + .sidebar { + transform: translateX(-100%); + transition: transform var(--transition-normal); + z-index: 90; + background: var(--bg-primary); + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 80; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal); + } + + .sidebar-overlay.visible { + opacity: 1; + pointer-events: auto; + } + + .content { + margin-left: 0; + padding: 24px 20px 60px; + } + + .page-header h1 { + font-size: 28px; + } + + .feature-grid { + grid-template-columns: 1fr; + } + + .two-col { + grid-template-columns: 1fr; + } + + .search-bar { + min-width: 0; + flex: 1; + max-width: 200px; + } + + .search-shortcut { + display: none; + } + + .search-modal-content { + width: calc(100% - 32px); + margin: 0 16px; + } + + .search-modal { + padding-top: 80px; + } + + .footer-nav { + flex-direction: column; + } + + .footer-prev, .footer-next { + min-width: 0; + } + + .footer-next { + margin-left: 0; + } + + .doc-table { + font-size: 13px; + } + + .doc-table th, .doc-table td { + padding: 8px 10px; + } + + h2 { + font-size: 20px; + } + + h3 { + font-size: 16px; + } +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-default); +} + +/* ===== Selection ===== */ +::selection { + background: rgba(99, 102, 241, 0.3); + color: var(--text-primary); +} + +/* ===== Smooth page transitions ===== */ +.page { + animation: fadeIn 200ms ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== Loading Spinner ===== */ +.page-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.loading-spinner { + width: 28px; + height: 28px; + border: 2.5px solid var(--border-subtle); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index a3dbecc..70544ba 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,6 +1,6 @@ # ReCursor — Implementation Plan -> **Flutter mobile app** providing OpenCode-like UI/UX for AI coding agents, with events sourced from Claude Code via supported integration mechanisms. +> **Flutter mobile app** providing OpenCode-like UI/UX for AI coding agents. Bridge-first, no-login: connects to your user-controlled desktop bridge. --- @@ -42,16 +42,24 @@ flowchart TB > ⚠️ **Claude Code Remote Control is first-party only** — there is no public API for third-party clients to join or mirror existing Claude Code sessions. -**Supported Integration Path:** +**Supported Integration Paths:** - **Claude Code Hooks** — HTTP-based event observation (one-way) - **Agent SDK** — Parallel agent sessions (not mirroring) - **MCP (Model Context Protocol)** — Tool interoperability +### Bridge-First, No-Login Workflow + +ReCursor uses a **bridge-first** connection model with no user accounts: +- **No sign-in required** — the app opens to bridge pairing/restore, not a login screen +- **User-controlled bridge** — the bridge runs on your development machine, not a hosted service +- **Secure device pairing** — QR code pairing with device tokens stored in secure storage +- **Remote access** — optional secure tunneling (Tailscale, WireGuard) to your own bridge + --- ## Phase 1: Foundation -**Goal:** Bootable app with auth, secure connectivity to bridge, and basic agent chat with OpenCode-style UI. +**Goal:** Bootable app with direct bridge connectivity (no auth flow) and basic agent chat with OpenCode-style UI. ### 1.1 Project Scaffolding & CI/CD - [ ] Initialize Flutter project (iOS + Android targets) @@ -61,21 +69,15 @@ flowchart TB - [ ] Configure Fastlane for iOS (Match) and Android (keystore) - [ ] **Tests:** Verify project builds on both platforms -### 1.2 Authentication -- [ ] Implement GitHub OAuth2 flow -- [ ] Support Personal Access Token (PAT) auth as fallback -- [ ] Secure token storage via `flutter_secure_storage` -- [ ] Auth state management with Riverpod -- [ ] **Tests:** Unit test auth provider state transitions - -### 1.3 Bridge Connection & Security +### 1.2 Bridge Connection & Security (First Screen) - [ ] Define WebSocket protocol (see [bridge-protocol.md](bridge-protocol.md)) - [ ] Implement WebSocket client service with `web_socket_channel` -- [ ] Connection pairing via QR code (encode bridge URL + auth token) +- [ ] Connection pairing via QR code (encode bridge URL + device pairing token) +- [ ] Restore saved bridge pairings on startup before entering the main shell - [ ] Tailscale integration documentation / setup guide - [ ] Always use `wss://`; optional certificate pinning - [ ] Auto-reconnect with exponential backoff -- [ ] **Tests:** Unit test WebSocket service with mocks +- [ ] **Tests:** Unit test WebSocket service with mocks and startup restore logic ### 1.4 Basic Agent Chat Interface (OpenCode-style) - [ ] Chat UI with message list (user messages + agent responses) @@ -91,7 +93,7 @@ flowchart TB - [ ] File viewer with syntax highlighting - [ ] **Tests:** Unit test bridge file commands -**Phase 1 Deliverable:** App authenticates via GitHub, connects to bridge, sends messages to Agent SDK session, displays streamed responses with OpenCode-style UI. +**Phase 1 Deliverable:** App connects directly to bridge (no auth flow), sends messages to Agent SDK session, displays streamed responses with OpenCode-style UI. --- @@ -224,7 +226,7 @@ flowchart TB | **Unit** | `flutter_test` + `mockito` | Services, providers, models, serialization | | **Widget** | `flutter_test` | UI components with mock dependencies | | **Golden** | `alchemist` | Visual regression for key screens | -| **Integration** | `integration_test` | Full flows: auth → connect → chat → hooks | +| **Integration** | `integration_test` | Full flows: connect → chat → hooks | | **E2E** | `patrol` | Complete user journeys on real devices | --- @@ -236,7 +238,7 @@ flowchart TB | Framework | Flutter | Cross-platform, CC Pocket precedent | | State | Riverpod | Type-safe, testable, Conduit pattern | | Networking | `web_socket_channel` | Standard Flutter WebSocket | -| Auth | GitHub OAuth2 + PAT | `github_oauth` package | +| UI State | Riverpod | Type-safe state management | | Local DB | Drift (SQLite) | Type-safe queries, migrations | | Cache | Hive | Fast key-value for ephemeral data | | Notifications | `flutter_local_notifications` | Local alerts when backgrounded | diff --git a/docs/README.md b/docs/README.md index cfb4560..e478a4e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # ReCursor Documentation -> **ReCursor** — A Flutter mobile app providing OpenCode-like UI/UX for AI coding agents, with events sourced from Claude Code via supported integration mechanisms (Hooks/Agent SDK). +> **ReCursor** — A Flutter mobile app providing OpenCode-like UI/UX for AI coding agents. Bridge-first, no-login workflow: connects to your user-controlled desktop bridge via secure tunnel. --- @@ -101,10 +101,12 @@ flowchart TB > ⚠️ **Claude Code Remote Control Protocol**: The Remote Control feature is designed exclusively for first-party Anthropic clients. There is no public API for third-party clients to join or mirror existing Claude Code sessions. > -> **Supported Integration Path:** +> **Supported Integration Paths:** > - **Claude Code Hooks** — HTTP-based event observation (one-way) > - **Agent SDK** — Parallel agent sessions (not mirroring) > - **MCP (Model Context Protocol)** — Tool interoperability +> +> **Bridge-First Workflow:** ReCursor uses a bridge-first, no-login model. The mobile app connects directly to a user-controlled desktop bridge. No hosted accounts, no sign-in required — just secure device pairing via QR code and optional tunneling for remote access. --- diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7d33a6e..9f5da96 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ # Architecture Overview -> System architecture for ReCursor: a Flutter mobile app with OpenCode-like UI consuming Claude Code events via supported integration mechanisms. +> System architecture for ReCursor: a Flutter mobile app with OpenCode-like UI. Bridge-first, no-login: connects to your user-controlled desktop bridge via secure tunnel. --- @@ -46,12 +46,12 @@ flowchart TB | Approach | Status | Notes | |----------|--------|-------| -| Direct Remote Control Protocol | ❌ Not Available | First-party only (claude.ai/code, official apps) | +| Direct Remote Control Protocol | ❌ Not Available | First-party only (claude.ai/code, official apps). No public API for third-party clients. | | **Claude Code Hooks** | ✅ Supported | HTTP-based event observation (one-way) | -| **Agent SDK** | ✅ Supported | Parallel agent sessions | +| **Agent SDK** | ✅ Supported | Parallel agent sessions (not mirroring) | | MCP (Model Context Protocol) | ✅ Supported | Tool interoperability | -**Selected Architecture:** Hybrid approach using Hooks for event observation + Agent SDK for parallel sessions. +**Selected Architecture:** Hybrid approach using Hooks for event observation + Agent SDK for parallel sessions. ReCursor does not claim to mirror or control existing Claude Code sessions. ### 2. UI/UX Pattern @@ -114,7 +114,7 @@ flowchart LR subgraph Network["Network Layers"] WireGuard["WireGuard/Tailscale\n(Network Layer)"] TLS["TLS 1.3\n(Transport Layer)"] - Auth["Token Auth\n(Application Layer)"] + Auth["Device Pairing Token\n(Application Layer)"] end Phone["📱 Mobile"] --> WireGuard @@ -123,9 +123,9 @@ flowchart LR Auth --> Bridge["Bridge Server"] ``` -1. **Network Layer**: Tailscale/WireGuard mesh VPN +1. **Network Layer**: Tailscale/WireGuard mesh VPN (or your preferred secure tunnel) 2. **Transport Layer**: WSS (WebSocket Secure) with TLS 1.3 -3. **Application Layer**: Token-based authentication on WebSocket handshake +3. **Application Layer**: Device pairing token on WebSocket handshake (no user accounts, no login) --- diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index 1a0dbf5..454ab39 100644 --- a/docs/bridge-protocol.md +++ b/docs/bridge-protocol.md @@ -1,6 +1,6 @@ # Bridge Protocol Specification -> WebSocket message protocol between the ReCursor Flutter mobile app and the TypeScript bridge server. +> WebSocket message protocol between the ReCursor Flutter mobile app and the user-controlled TypeScript bridge server. Bridge-first, no-login: device pairing via QR code, no user accounts. --- @@ -50,14 +50,14 @@ All messages are JSON objects with a `type` field and an optional `id` for reque ### Connection #### `auth` (client -> server) -Sent immediately after WebSocket connection opens. +Sent immediately after WebSocket connection opens for device pairing authentication. ```json { "type": "auth", "id": "auth-001", "payload": { - "token": "bridge-auth-token-xxxxx", + "token": "device-pairing-token-xxxxx", "client_version": "1.0.0", "platform": "ios" } @@ -75,7 +75,7 @@ Confirms authentication and connection. "server_version": "1.0.0", "supported_agents": ["claude-code", "opencode", "aider", "goose"], "active_sessions": [ - { "session_id": "sess-abc", "agent": "claude-code", "title": "Fix auth bug" } + { "session_id": "sess-abc", "agent": "claude-code", "title": "Bridge startup validation" } ] } } @@ -170,7 +170,7 @@ Send a chat message to the agent. "id": "msg-001", "payload": { "session_id": "sess-abc123", - "content": "Fix the auth bug in login.dart", + "content": "Tighten the bridge startup validation in bridge_setup_screen.dart", "role": "user" } } @@ -198,7 +198,7 @@ Chunk of streamed response content. "payload": { "session_id": "sess-abc123", "message_id": "msg-resp-001", - "content": "I'll help you fix the auth bug", + "content": "I'll tighten the bridge startup validation", "is_tool_use": false } } @@ -234,11 +234,11 @@ Agent wants to use a tool. Sent when Agent SDK initiates tool use. "tool_call_id": "call-abc123", "tool": "edit_file", "params": { - "file_path": "/home/user/project/lib/auth.dart", - "old_string": "void login() {", - "new_string": "void login() async {" + "file_path": "/home/user/project/lib/features/startup/bridge_setup_screen.dart", + "old_string": "return wsAllowed(url);", + "new_string": "return requireWss(url);" }, - "description": "Add async keyword to login function" + "description": "Require secure bridge URLs during startup" } } ``` @@ -343,12 +343,12 @@ Current git status. "id": "git-001", "payload": { "session_id": "sess-abc123", - "branch": "feature/auth-fix", + "branch": "feature/bridge-startup", "ahead": 2, "behind": 0, "is_clean": false, "changes": [ - { "path": "lib/auth.dart", "status": "modified", "additions": 5, "deletions": 2 } + { "path": "lib/features/startup/bridge_setup_screen.dart", "status": "modified", "additions": 5, "deletions": 2 } ] } } @@ -363,8 +363,8 @@ Create a commit. "id": "git-002", "payload": { "session_id": "sess-abc123", - "message": "Fix auth bug in login flow", - "files": ["lib/auth.dart"] // null = all staged + "message": "Tighten bridge startup validation", + "files": ["lib/features/startup/bridge_setup_screen.dart"] // null = all staged } } ``` @@ -378,7 +378,7 @@ Request diff for files. "id": "git-003", "payload": { "session_id": "sess-abc123", - "files": ["lib/auth.dart"], // null = all changes + "files": ["lib/features/startup/bridge_setup_screen.dart"], // null = all changes "cached": false } } @@ -395,9 +395,9 @@ Diff content. "session_id": "sess-abc123", "files": [ { - "path": "lib/auth.dart", - "old_path": "lib/auth.dart", - "new_path": "lib/auth.dart", + "path": "lib/features/startup/bridge_setup_screen.dart", + "old_path": "lib/features/startup/bridge_setup_screen.dart", + "new_path": "lib/features/startup/bridge_setup_screen.dart", "status": "modified", "additions": 5, "deletions": 2, @@ -409,9 +409,9 @@ Diff content. "new_start": 10, "new_lines": 5, "lines": [ - { "type": "context", "content": " class AuthService {" }, - { "type": "removed", "content": "- void login() {" }, - { "type": "added", "content": "+ void login() async {" }, + { "type": "context", "content": " class BridgeConnectionValidator {" }, + { "type": "removed", "content": "- return wsAllowed(url);" }, + { "type": "added", "content": "+ return requireWss(url);" }, { "type": "context", "content": " // ..." } ] } @@ -467,7 +467,7 @@ Read file content. "id": "file-002", "payload": { "session_id": "sess-abc123", - "path": "/home/user/project/lib/auth.dart", + "path": "/home/user/project/lib/features/startup/bridge_setup_screen.dart", "offset": 0, "limit": 100 } @@ -505,8 +505,8 @@ Server-initiated notification. "payload": { "session_id": "sess-abc123", "notification_type": "approval_required", - "title": "Approval needed: Edit login.dart", - "body": "Claude Code wants to change the OAuth callback URL.", + "title": "Approval needed: Update bridge_setup_screen.dart", + "body": "Claude Code wants to tighten bridge URL validation before pairing.", "priority": "high", "data": { "tool_call_id": "tool-001", diff --git a/docs/data-models.md b/docs/data-models.md index 4535ccd..124a681 100644 --- a/docs/data-models.md +++ b/docs/data-models.md @@ -61,7 +61,7 @@ class Agents extends Table { TextColumn get displayName => text()(); // "Claude Code" TextColumn get agentType => text()(); // "claude-code", "opencode", "aider", "goose", "custom" TextColumn get bridgeUrl => text()(); // "wss://100.78.42.15:3000" - TextColumn get authToken => text()(); // Encrypted bridge auth token + TextColumn get authToken => text()(); // Encrypted bridge pairing token (device-bridge auth, not user account) TextColumn get workingDirectory => text().nullable()(); TextColumn get status => text() .withDefault(const Constant('disconnected'))(); // "connected", "disconnected", "inactive" @@ -138,22 +138,22 @@ class TerminalSessions extends Table { ## Hive Boxes (Key-Value) -### Auth Box +### Connection Box ```dart @HiveType(typeId: 1) -class AuthState { +class BridgeConnectionState { @HiveField(0) - final String accessToken; + final String deviceToken; @HiveField(1) - final String refreshToken; + final String bridgeUrl; @HiveField(2) - final DateTime expiresAt; + final DateTime pairedAt; @HiveField(3) - final String tokenType; // "oauth" | "pat" + final String tokenType; // "device_pairing" } ``` diff --git a/docs/idea.md b/docs/idea.md index a7819c2..d2d64b9 100644 --- a/docs/idea.md +++ b/docs/idea.md @@ -1,6 +1,6 @@ # ReCursor — Project Concept -> **Mobile-first AI coding agent controller** — Control Claude Code and other AI coding assistants from your mobile device (iOS/Android) with an OpenCode-inspired UI. +> **Mobile-first AI coding agent companion** — Connect to your user-controlled desktop bridge to observe and interact with Claude Code and other AI coding assistants from your mobile device (iOS/Android) with an OpenCode-inspired UI. Bridge-first, no-login workflow. --- @@ -46,21 +46,23 @@ flowchart LR App["ReCursor Flutter App\n(OpenCode-like UI)"] end - subgraph DevMachine["💻 Development Machine"] - Bridge["Bridge Server"] + subgraph DevMachine["💻 User-Controlled Development Machine"] + Bridge["ReCursor Bridge Server"] CCHooks["Claude Code Hooks\n(Event Observer)"] CC["Claude Code CLI"] end - App <-->|WebSocket| Bridge - Bridge <-->|HTTP| CCHooks + App <-->|WebSocket (wss://)| Bridge + Bridge <-->|HTTP POST| CCHooks CCHooks -->|Observes| CC ``` **Integration Strategy:** -- **Event Source**: Claude Code Hooks POST events to the bridge server +- **Bridge-First**: Mobile app connects directly to user-controlled bridge (no hosted service, no login) +- **Event Source**: Claude Code Hooks POST events to the bridge server (one-way observation) - **UI Pattern**: OpenCode-style tool cards, diff viewer, session timeline -- **Session Model**: Parallel Agent SDK sessions (not direct mirroring) +- **Session Model**: Parallel Agent SDK sessions (not mirroring existing Claude Code sessions) +- **Remote Access**: Secure tunnel (Tailscale, WireGuard) to your own bridge — not unsupported third-party Claude Remote Control --- @@ -71,7 +73,7 @@ flowchart LR | Framework | Flutter | Cross-platform, CC Pocket precedent | | State | Riverpod | Type-safe, testable, Conduit pattern | | Networking | `web_socket_channel` | Standard Flutter WebSocket | -| Auth | GitHub OAuth2 + PAT | `github_oauth` package | +| Device Pairing | QR code + token | Bridge-first connection | | Local DB | Drift (SQLite) | Type-safe queries, migrations | | Cache | Hive | Fast key-value for ephemeral data | | Bridge | TypeScript (Node.js) | CC Pocket pattern | diff --git a/docs/integration/agent-sdk.md b/docs/integration/agent-sdk.md index a7d8c15..0555061 100644 --- a/docs/integration/agent-sdk.md +++ b/docs/integration/agent-sdk.md @@ -1,6 +1,6 @@ # Agent SDK Integration -> Using the Claude Agent SDK for parallel agent sessions in ReCursor. +> Using the Claude Agent SDK for parallel agent sessions in ReCursor. This is a supported integration path — ReCursor does not claim to mirror or control existing Claude Code sessions via unsupported Remote Control protocols. --- @@ -88,7 +88,7 @@ const agent = new Agent({ // Start a conversation const response = await agent.run({ - messages: [{ role: 'user', content: 'Fix the auth bug in login.dart' }], + messages: [{ role: 'user', content: 'Tighten the bridge startup validation in bridge_setup_screen.dart' }], }); ``` diff --git a/docs/integration/claude-code-hooks.md b/docs/integration/claude-code-hooks.md index e9e17b6..cd16ae4 100644 --- a/docs/integration/claude-code-hooks.md +++ b/docs/integration/claude-code-hooks.md @@ -1,6 +1,6 @@ # Claude Code Hooks Integration -> Configure Claude Code Hooks to POST events to the ReCursor bridge server for mobile consumption. +> Configure Claude Code Hooks to POST events to the ReCursor bridge server for mobile consumption. This is a supported integration path for one-way event observation — not a Remote Control protocol. --- @@ -340,9 +340,9 @@ eventBus.on('claude-event', broadcastToMobile); "session_id": "sess-abc123", "timestamp": "2026-03-17T10:35:00Z", "payload": { - "prompt": "Add error handling to the login function", + "prompt": "Add error handling to the bridge setup reconnect flow", "context": { - "current_file": "lib/auth.dart", + "current_file": "lib/features/startup/bridge_setup_screen.dart", "cursor_position": 145 } } diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md new file mode 100644 index 0000000..a6368e8 --- /dev/null +++ b/docs/legal/privacy-policy.md @@ -0,0 +1,42 @@ +# Privacy Policy + +**Last updated: 2026-03-17** + +## Overview +ReCursor ("we", "our", "the app") is an open-source mobile application for monitoring AI coding agent workflows. This policy describes what data we collect, how we use it, and your rights. + +## Data We Collect + +### Data stored locally on your device +- **Bridge connection**: Bridge server URLs and device pairing tokens, stored encrypted via secure keychain +- **Agent configurations**: Working directories and agent preferences, stored in the app's local database +- **Session history**: Chat messages and tool call records from your AI agent sessions, stored locally in SQLite +- **App preferences**: Theme settings and notification preferences, stored in local key-value storage + +### Data we do NOT collect +- We do not operate any servers or collect any telemetry by default +- We do not transmit your code, files, or session data to any third party +- We do not use advertising identifiers +- We do not track your location + +### Optional analytics (opt-in only) +If you explicitly enable analytics in Settings, the app logs anonymized usage events locally. These events are never transmitted unless you configure a self-hosted analytics endpoint. + +## Data Transmission +ReCursor communicates only with: +1. **Your bridge server**: The app connects directly to the ReCursor bridge server running on your own machine via WebSocket. You control this server. +2. **Anthropic API**: If using the Agent SDK integration, requests are made via your bridge server using your own API key. ReCursor does not have access to your Anthropic API key. + +## Security +- Bridge pairing tokens are stored using iOS Keychain / Android Keystore via `flutter_secure_storage` +- All bridge connections use WSS (TLS-encrypted WebSocket) +- We recommend using Tailscale or WireGuard for bridge connectivity + +## Your Rights +You can delete all locally stored data by uninstalling the app or using "Reset App" in Settings. + +## Changes +We will update this policy as the app evolves. Check the app's GitHub repository for the latest version. + +## Contact +Questions? Open an issue at https://github.com/RecursiveDev/ReCursor/issues diff --git a/docs/legal/terms-of-service.md b/docs/legal/terms-of-service.md new file mode 100644 index 0000000..bb4b276 --- /dev/null +++ b/docs/legal/terms-of-service.md @@ -0,0 +1,28 @@ +# Terms of Service + +**Last updated: 2026-03-17** + +## Acceptance +By using ReCursor, you agree to these terms. + +## What ReCursor Is +ReCursor is an open-source mobile companion app for AI coding agent workflows. It connects to a bridge server you run on your own machine. + +## Your Responsibilities +- You are responsible for the security of your bridge server +- You are responsible for your Anthropic API key usage and costs +- You must comply with Anthropic's usage policies when using the Agent SDK +- Do not use ReCursor to automate actions you are not authorized to perform + +## Disclaimer +ReCursor is provided "as is" without warranty. The developers are not responsible for: +- Code changes made by AI agents through the app +- API costs incurred through Agent SDK usage +- Data loss from local database corruption +- Security issues arising from misconfigured bridge servers + +## Open Source +ReCursor is MIT-licensed. See LICENSE for details. + +## Changes +We may update these terms. Continued use constitutes acceptance. diff --git a/docs/project-structure.md b/docs/project-structure.md index bcc8da9..cf179ec 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -32,7 +32,7 @@ recursor/ │ │ ├── hooks/ # Claude Code Hooks receiver │ │ ├── git/ # Git operation handlers │ │ ├── terminal/ # Terminal session manager -│ │ ├── auth/ # Token validation, rate limiting +│ │ ├── auth/ # Device token validation, rate limiting │ │ └── notifications/ # Event queue + WebSocket dispatch │ ├── package.json │ └── tsconfig.json @@ -64,13 +64,12 @@ core/ │ ├── websocket_messages.dart # Message type definitions (from bridge-protocol.md) │ └── connection_state.dart # Connection state enum + notifier │ -├── auth/ -│ ├── auth_provider.dart # Riverpod auth state provider -│ ├── auth_repository.dart # OAuth + PAT token management -│ ├── token_storage.dart # flutter_secure_storage wrapper -│ └── github_oauth.dart # OAuth2 flow handler +├── providers/ +│ ├── token_storage_provider.dart # Secure bridge token storage provider +│ └── websocket_provider.dart # Shared WebSocket service providers │ ├── storage/ +│ ├── secure_token_storage.dart # flutter_secure_storage wrapper for bridge pairing │ ├── database.dart # Drift database definition │ ├── tables/ # Drift table definitions │ │ ├── sessions.dart @@ -231,19 +230,13 @@ features/ │ └── widgets/ │ └── agent_card.dart │ -├── auth/ # Authentication -│ ├── data/ -│ │ └── repositories/ -│ │ └── auth_repository.dart +├── startup/ # Bridge-first launch and pairing restore │ ├── domain/ -│ │ └── providers/ -│ │ └── auth_provider.dart +│ │ └── bridge_startup_controller.dart │ └── presentation/ -│ ├── screens/ -│ │ ├── login_screen.dart -│ │ └── splash_screen.dart -│ └── widgets/ -│ └── auth_button.dart +│ └── screens/ +│ ├── splash_screen.dart +│ └── bridge_setup_screen.dart │ └── settings/ # App settings └── presentation/ @@ -311,7 +304,7 @@ bridge/ │ └── output_stream.ts # Terminal output streaming │ ├── auth/ -│ ├── token_validator.ts # JWT/auth token validation +│ ├── token_validator.ts # Device pairing token validation │ └── rate_limiter.ts # Rate limiting │ └── notifications/ diff --git a/docs/push-notifications.md b/docs/push-notifications.md index c0625a9..c26e7b7 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -55,8 +55,8 @@ All notifications flow through the existing WebSocket connection. No external pu "payload": { "session_id": "sess-abc123", "notification_type": "approval_required", - "title": "Approval needed: Edit login.dart", - "body": "Claude Code wants to change the OAuth callback URL.", + "title": "Approval needed: Update bridge_setup_screen.dart", + "body": "Claude Code wants to tighten bridge URL validation before pairing.", "priority": "high", "data": { "tool_call_id": "tool-001", diff --git a/docs/research.md b/docs/research.md index 1087fce..3c59b8a 100644 --- a/docs/research.md +++ b/docs/research.md @@ -64,6 +64,8 @@ The emerging architectural pattern across all mobile coding agent clients is a * ## Flutter GitHub client apps provide battle-tested architectural patterns +> **Note:** This section documents GitHub OAuth patterns as prior research for mobile GitHub client architecture. ReCursor's current direction is **bridge-first with no user authentication** — repository operations are delegated to the agent running on the development machine. Git operations via bridge commands, not native mobile OAuth. + For GitHub OAuth, repository browsing, and git operations on mobile, three codebases stand out as architectural references. **GSYGithubAppFlutter** ([CarGuo/gsy_github_app_flutter](https://github.com/CarGuo/gsy_github_app_flutter)) at **15.4K stars** is the definitive Flutter GitHub client. Updated through February 2026, it uniquely demonstrates Redux, Provider, Riverpod, and Signals state management in the same project. Its four-layer architecture (UI → State → Service → Data) with repository pattern, event bus, and SQL caching provides a complete blueprint. Features include full GitHub OAuth (using custom URL scheme `gsygithubapp://authed`), repo/issue/PR browsing, trending repos, search, markdown rendering, and i18n. **Apache 2.0 licensed** with 2.6K forks. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 5be7d0d..6b6e52b 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -6,13 +6,13 @@ ## Network Layer -- **Use Tailscale as the primary networking layer.** It wraps WireGuard encryption, handles NAT traversal, and creates a zero-config mesh VPN between phone and dev machine. DERP relay servers never see unencrypted data. -- **Always use `wss://` (WebSocket Secure).** TLS at the application layer + WireGuard at the network layer = defense in depth. -- **Never expose the bridge on a public IP.** The bridge should only be reachable within the Tailscale network or via SSH tunnel. +- **Use a secure tunnel for remote access.** Tailscale (recommended) wraps WireGuard encryption, handles NAT traversal, and creates a zero-config mesh VPN between phone and dev machine. DERP relay servers never see unencrypted data. Other options include WireGuard, Cloudflare Tunnel, or SSH tunneling. +- **Always use `wss://` (WebSocket Secure).** TLS at the application layer + tunnel encryption at the network layer = defense in depth. +- **Never expose the bridge on a public IP without tunnel protection.** The bridge should only be reachable within your secure tunnel network. --- -## Authentication Flow +## Bridge Connection Security ```mermaid sequenceDiagram @@ -20,9 +20,9 @@ sequenceDiagram participant Bridge as Bridge Server participant Hooks as Claude Code Hooks - Note over Mobile,Hooks: Connection Authentication - Mobile->>Bridge: wss:// + auth_token - Bridge->>Bridge: Validate token (Firebase/JWT) + Note over Mobile,Hooks: Connection Pairing + Mobile->>Bridge: wss:// connect + device_token + Bridge->>Bridge: Validate device_token Bridge-->>Mobile: connection_ack Note over Mobile,Hooks: Hook Event Authentication @@ -35,21 +35,21 @@ sequenceDiagram | Token Type | Purpose | Storage | |------------|---------|---------| -| Bridge Auth Token | Authenticate mobile app to bridge | `flutter_secure_storage` | +| Device Pairing Token | Authenticate mobile app to bridge (generated at pairing) | `flutter_secure_storage` | | Hook Token | Authenticate Claude Code Hooks to bridge | Bridge server env only | -| GitHub OAuth Token | GitHub API access | `flutter_secure_storage` | -| GitHub PAT | Fallback auth | `flutter_secure_storage` | --- ## Token Management -### Bridge Auth Token +### Device Pairing Token -- **Generate**: 32+ character random string (crypto-safe) -- **Storage**: Bridge server environment variable +- **Generate**: 32+ character random string (crypto-safe), generated during bridge setup +- **Storage**: Bridge server environment variable or config file - **Mobile**: Encrypted with `flutter_secure_storage` (Keychain/EncryptedSharedPreferences) - **QR Code**: Bridge URL + token encoded for easy pairing +- **No User Accounts**: Tokens are per-device, not tied to any user identity or hosted account +- **Bridge-First**: No login flow — the app opens to bridge pairing/restore, not sign-in ```dart // Token generation (bridge server) @@ -59,9 +59,9 @@ const token = crypto.randomBytes(32).toString('hex'); // 64 chars ### Token Rotation -- Rotate bridge auth tokens on suspicious activity -- Invalidate tokens on logout -- Support token revocation list +- Rotate device tokens if bridge is reinstalled or security concern arises +- Clear token from mobile app via "Disconnect Bridge" in Settings +- Support token revocation list on bridge server --- diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index b8a754b..37a8c56 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -55,7 +55,7 @@ expectLater( ### What to Unit Test - WebSocket service (connect, disconnect, reconnect, message parsing) -- Auth provider state transitions (unauthenticated -> authenticating -> authenticated -> error) +- Bridge connection state transitions (disconnected -> connecting -> connected -> error) - Git command serialization/deserialization - Notification payload parsing - Diff parsing logic @@ -88,7 +88,7 @@ testWidgets('shows connected status', (tester) async { ### What to Widget Test - Chat UI with mock message streams -- Login screen form validation and submission +- Bridge QR pairing screen - OpenCode-style Tool Cards with sample data - Diff viewer with sample diff data - Approval UI approve/reject/modify interactions @@ -167,7 +167,7 @@ testWidgets('full chat flow', (tester) async { ### What to Integration Test -- Auth -> connect -> chat -> receive response +- Bridge connect -> validate pairing -> chat -> receive response - Git operation flows (commit, push, pull) - Approval flow (receive tool call -> approve -> agent continues) - Offline -> reconnect -> sync @@ -213,7 +213,7 @@ class TestBridgeServer { ### E2E Scenarios -- Full onboarding flow: install -> auth -> pair -> first message +- Full onboarding flow: install -> bridge pairing -> first message - Background notification: receive approval request -> tap notification -> approve - Multi-session: switch between agent sessions - Offline workflow: actions while offline -> sync on reconnect diff --git a/docs/wireframes/01-auth.md b/docs/wireframes/01-auth.md deleted file mode 100644 index 2d370cc..0000000 --- a/docs/wireframes/01-auth.md +++ /dev/null @@ -1,125 +0,0 @@ -# 01 - Authentication Screens - -> Phase 1 — Login, OAuth, and onboarding. - ---- - -## 1A. Splash Screen - -``` -+---------------------------------------+ -| | -| | -| | -| | -| [ App Logo ] | -| | -| ReCursor | -| AI Coding Agents, Anywhere | -| | -| | -| ( loading spinner ) | -| | -| | -| | -+---------------------------------------+ -``` - -**Behavior:** -- Auto-checks for stored auth token -- If valid token found -> navigate to Bridge Pairing or Main Shell -- If no token -> navigate to Login Screen -- Duration: 1-2s max - ---- - -## 1B. Login Screen - -``` -+---------------------------------------+ -| ReCursor | -+---------------------------------------+ -| | -| [ App Logo ] | -| | -| Control your AI coding agents | -| from anywhere. | -| | -| | -| +----------------------------------+ | -| | | | -| | [G] Sign in with GitHub | | -| | | | -| +----------------------------------+ | -| | -| | -| ---- or ---- | -| | -| | -| +----------------------------------+ | -| | Personal Access Token | | -| | +----------------------------+ | | -| | | ghp_xxxxxxxxxxxxxxxxxxxx | | | -| | +----------------------------+ | | -| | | | -| | [ Connect with PAT ] | | -| +----------------------------------+ | -| | -| | -| By continuing, you agree to the | -| Terms of Service & Privacy Policy | -| | -+---------------------------------------+ -``` - -**Elements:** -- GitHub OAuth button (primary, filled, with GitHub icon) -- PAT input section (expandable/collapsible, secondary option) -- PAT field: obscured text input with show/hide toggle -- Legal links at bottom - -**States:** -- Default: OAuth button prominent, PAT collapsed -- PAT expanded: text field visible with connect button -- Loading: button shows spinner, inputs disabled -- Error: red banner below input with message ("Invalid token", "Auth failed") - ---- - -## 1C. OAuth WebView - -``` -+---------------------------------------+ -| [X] GitHub Login | -+---------------------------------------+ -| | -| +----------------------------------+ | -| | | | -| | github.com/login/oauth | | -| | | | -| | Username or email address | | -| | +----------------------------+ | | -| | | | | | -| | +----------------------------+ | | -| | | | -| | Password | | -| | +----------------------------+ | | -| | | | | | -| | +----------------------------+ | | -| | | | -| | [ Sign in ] | | -| | | | -| +----------------------------------+ | -| | -| ( progress bar at top of webview ) | -| | -+---------------------------------------+ -``` - -**Behavior:** -- Opens GitHub OAuth authorize URL in in-app WebView -- Intercepts redirect to `remotecli://authed?code=xxx` -- Exchanges code for token in background -- On success: close WebView, navigate to Bridge Pairing -- On failure: show error toast, return to Login Screen -- [X] button cancels and returns to Login diff --git a/docs/wireframes/01-startup.md b/docs/wireframes/01-startup.md new file mode 100644 index 0000000..1734b67 --- /dev/null +++ b/docs/wireframes/01-startup.md @@ -0,0 +1,73 @@ +# 01 - Startup & Bridge Restore + +> Phase 1 — bridge-first launch, saved pairing restore, and handoff to bridge setup. + +--- + +## 1A. Splash / Restore Screen + +``` ++---------------------------------------+ +| | +| | +| | +| | +| [ App Logo ] | +| | +| ReCursor | +| Restore Bridge Session | +| | +| Checking for a saved bridge pair... | +| | +| ( loading spinner ) | +| | +| | +| | ++---------------------------------------+ +``` + +**Behavior:** +- Auto-checks for a saved bridge URL and bridge pairing token +- If a valid saved pairing reconnects successfully -> navigate to Main Shell +- If no pairing exists or reconnect fails -> navigate to Bridge Setup +- Duration: 1-2s max while restore runs + +--- + +## 1B. Restore Failure Handoff + +``` ++---------------------------------------+ +| Bridge Setup | ++---------------------------------------+ +| | +| Unable to reconnect to the saved | +| bridge. Check the URL or pairing | +| token below and try again. | +| | +| Bridge URL | +| +-------------------------------+ | +| | wss://devbox.tailnet.ts.net | | +| +-------------------------------+ | +| | +| Bridge Pairing Token | +| +-------------------------------+ | +| | •••••••••••••••••••••••••••• | | +| +-------------------------------+ | +| | +| [ Reconnect to Bridge ] | +| | ++---------------------------------------+ +``` + +**Elements:** +- Error banner explaining why startup fell back to setup +- Saved bridge URL prefilled for quick correction +- Secure pairing token field with masked value +- Primary reconnect button + +**States:** +- Restore failed: error copy visible and fields prefilled +- Missing pairing: no error, blank fields +- Loading: reconnect button shows spinner, inputs disabled +- Success: navigate directly to Sessions list diff --git a/docs/wireframes/03-chat.md b/docs/wireframes/03-chat.md index e4e436f..1b15731 100644 --- a/docs/wireframes/03-chat.md +++ b/docs/wireframes/03-chat.md @@ -12,9 +12,9 @@ +---------------------------------------+ | | | +----------------------------------+ | -| | Fix auth bug in login.dart | | +| | Improve bridge reconnect banner | | | | Claude Code * 2 min ago | | -| | "I've updated the OAuth..." | | +| | "I've updated the reconnect..." | | | +----------------------------------+ | | | | +----------------------------------+ | @@ -52,29 +52,29 @@ ``` +---------------------------------------+ -| [<] Fix auth bug (*) Connected | +| [<] Bridge reconnect (*) Connected | | Claude Code * main branch | +---------------------------------------+ | | | +----------------------------------+ | | | You 10:32 AM | | -| | Fix the OAuth redirect bug in | | -| | lib/auth/login.dart. The | | -| | callback URL is wrong. | | +| | Tighten the bridge startup | | +| | validation in | | +| | lib/features/startup/... | | | +----------------------------------+ | | | | +-----------------------------+| | | Claude Code 10:32 AM || | | || -| | I'll fix the OAuth redirect || -| | in `login.dart`. The issue || -| | is on line 42 where the || -| | callback URL uses `http` || -| | instead of `https`. || +| | I'll tighten the bridge || +| | validation in the startup || +| | screen. The saved URL must || +| | continue using `wss://` || +| | before the app reconnects. || | | || | | ```dart || | | // Before || -| | callbackUrl: 'http://...' || +| | bridgeUrl: 'ws://...' || | | // After || | | callbackUrl: 'https://...' || | | ``` || @@ -118,7 +118,7 @@ ``` +---------------------------------------+ -| [<] Fix auth bug (*) Connected | +| [<] Bridge startup (*) Connected | +---------------------------------------+ | | | ... (previous messages) ... | @@ -130,11 +130,11 @@ | | || | | +-------------------------+ || | | | TOOL: Edit File | || -| | | File: lib/auth/login.da | || +| | | File: startup/splash... | || | | | Lines: 42-45 | || | | | | || -| | | - url: 'http://cb' | || -| | | + url: 'https://cb' | || +| | | - allowWs(url); | || +| | | + requireWss(url); | || | | | | || | | | [Approve] [Reject] | || | | | [View Full] [Modify] | || @@ -156,7 +156,7 @@ ``` +---------------------------------------+ -| [<] Fix auth bug (*) Connected | +| [<] Bridge startup (*) Connected | +---------------------------------------+ | | | ... (previous messages) ... | diff --git a/docs/wireframes/04-repos.md b/docs/wireframes/04-repos.md index e48020d..6517e44 100644 --- a/docs/wireframes/04-repos.md +++ b/docs/wireframes/04-repos.md @@ -62,11 +62,11 @@ | lib/ | | > core/ | | v features/ | -| v auth/ | -| > data/ | +| v startup/ | | > domain/ | -| login_screen.dart | -| auth_provider.dart | +| bridge_startup_controller.. | +| bridge_setup_screen.dart | +| splash_screen.dart | | > chat/ | | > repos/ | | > shared/ | @@ -99,18 +99,18 @@ ``` +---------------------------------------+ -| [<] login_screen.dart [...] | -| lib/features/auth/ * 142 lines | +| [<] bridge_setup_screen.dart [...] | +| lib/features/startup/ * 196 lines | +---------------------------------------+ | 1 | import 'package:flutter/mat.. | | 2 | import 'package:riverpod/ri.. | | 3 | | -| 4 | class LoginScreen extends St.. | +| 4 | class BridgeSetupScreen exte.. | | 5 | @override | | 6 | Widget build(BuildContext c.. | | 7 | return Scaffold( | | 8 | appBar: AppBar( | -| 9 | title: Text('Login'), | +| 9 | title: Text('Bridge Setup'),| | 10 | ), | | 11 | body: Padding( | | 12 | padding: EdgeInsets... | diff --git a/docs/wireframes/05-git.md b/docs/wireframes/05-git.md index d9cd78a..726eae5 100644 --- a/docs/wireframes/05-git.md +++ b/docs/wireframes/05-git.md @@ -25,10 +25,10 @@ | | | Recent Activity | | +----------------------------------+ | -| | abc1234 Fix OAuth redirect | | +| | abc1234 Tighten bridge startup | | | | Nathan * 2 hours ago | | | +----------------------------------+ | -| | def5678 Add auth provider | | +| | def5678 Add pairing restore | | | | Nathan * 5 hours ago | | | +----------------------------------+ | | | ghi9012 Initial project setup | | @@ -65,7 +65,7 @@ | | feature/voice-input | | | | origin/feature/voice * +3 | | | +----------------------------------+ | -| | fix/oauth-redirect | | +| | fix/bridge-startup | | | | (no remote) * local only | | | +----------------------------------+ | | | @@ -126,20 +126,20 @@ | | | Commit message: | | +----------------------------------+ | -| | Fix OAuth redirect bug | | +| | Tighten bridge startup | | | +----------------------------------+ | -| | Updated callback URL from http | | -| | to https in login.dart. Added | | -| | validation for redirect URIs. | | +| | Require WSS bridge URLs and | | +| | keep the saved pairing token | | +| | before restoring the session. | | | | | | | +----------------------------------+ | | | | Changed files: [Select All] | | +----------------------------------+ | -| | [x] M lib/auth/login.dart | | +| | [x] M lib/features/startup/... | | | | +2 -1 [>] | | | +----------------------------------+ | -| | [x] M lib/auth/oauth.dart | | +| | [x] M test/startup_restore.. | | | | +15 -3 [>] | | | +----------------------------------+ | | | [ ] ? test/auth_test.dart | | @@ -191,8 +191,8 @@ | Push complete! | | | | 3 commits pushed to origin/main | -| abc1234 Fix OAuth redirect | -| def5678 Add URL validation | +| abc1234 Tighten bridge startup | +| def5678 Add session restore | | ghi9012 Update tests | | | | [ Done ] | diff --git a/docs/wireframes/06-diff.md b/docs/wireframes/06-diff.md index 7046962..2cc573d 100644 --- a/docs/wireframes/06-diff.md +++ b/docs/wireframes/06-diff.md @@ -9,25 +9,25 @@ ``` +---------------------------------------+ | [<] Changes 3 files changed| -| abc1234 Fix OAuth redirect | +| abc1234 Tighten bridge startup | +---------------------------------------+ | | | +5 -2 across 3 files | | | | +----------------------------------+ | -| | lib/auth/login.dart | | +| | lib/features/startup/splash... | | | | +2 -1 | | | | ████████░░ 80% changed | | | +----------------------------------+ | | | | +----------------------------------+ | -| | lib/auth/oauth.dart | | +| | lib/features/startup/bridge... | | | | +2 -1 | | | | ██░░░░░░░░ 20% changed | | | +----------------------------------+ | | | | +----------------------------------+ | -| | test/auth_test.dart | | +| | test/startup_restore_test.dart | | | | +1 -0 (new file) | | | | ██████████ 100% new | | | +----------------------------------+ | @@ -49,18 +49,18 @@ ``` +---------------------------------------+ -| [<] login.dart [Unified|Split] | +| [<] bridge_setup_screen.dart [Unified|Split] | | File 1 of 3 [< prev][next >]| +---------------------------------------+ | | -| @@ -40,7 +40,8 @@ class LoginScr... | +| @@ -40,7 +40,8 @@ class BridgeSet... | | | -| 40 | final config = OAuthConfig( | -| 41 | clientId: env.clientId, | -| 42 |- callbackUrl: 'http://localhost' | -| 42 |+ callbackUrl: 'https://localhos' | -| 43 |+ redirectValidation: true, | -| 44 | ); | +| 40 | final result = validator( | +| 41 | url: normalizedUrl, | +| 42 |- allowWs: true, | +| 42 |+ requireWss: true, | +| 43 |+ ensureTokenPresent: true, | +| 44 | ); | | 45 | | | | | @@ -78,4 +79,4 @@ void _handleRe... | @@ -92,18 +92,18 @@ ``` +-------------------------------------------------------------------+ -| [<] login.dart [Unified|Split] [< prev] [next >] | +| [<] bridge_setup_screen.dart [Unified|Split] [< prev] [next >] | +-------------------------------------------------------------------+ | OLD | NEW | +------------------------------+------------------------------------+ -| 40 | final config = OAuth.. | 40 | final config = OAuth.. | -| 41 | clientId: env.client.. | 41 | clientId: env.client.. | -| 42 | callbackUrl: 'http://. | 42 | callbackUrl: 'https://. | -| | | 43 | redirectValidation: true, | +| 40 | final validator = Br.. | 40 | final validator = Br.. | +| 41 | url: normalizedUrl, | 41 | url: normalizedUrl, | +| 42 | allowWs: true, | 42 | requireWss: true, | +| | | 43 | ensureTokenPresent: true, | | 43 | ); | 44 | ); | | | | | -| 78 | if (uri.scheme != ... | 79 | if (uri.scheme != ... | -| 79 | throw AuthError('Inv.. | 79 | throw AuthError('Invalid p.. | +| 78 | if (token.isEmpty) .. | 79 | if (token.isEmpty) .. | +| 79 | return invalid(..) | 79 | return invalid(..) | | 80 | } | 80 | } | +------------------------------+------------------------------------+ ``` @@ -121,7 +121,7 @@ +---------------------------------------+ | | | Comment on line 42: | -| login.dart | +| bridge_setup_screen.dart | | | | +----------------------------------+| | | Should we also update the || diff --git a/docs/wireframes/07-approvals.md b/docs/wireframes/07-approvals.md index a082be8..2a985d0 100644 --- a/docs/wireframes/07-approvals.md +++ b/docs/wireframes/07-approvals.md @@ -14,10 +14,10 @@ | Claude Code wants to: | | | | Edit File | -| lib/auth/login.dart | +| lib/features/startup/bridge_... | | | -| - callbackUrl: 'http://...' | -| + callbackUrl: 'https://...' | +| - allowWs: true | +| + requireWss: true | | | | [View Full Diff] | | | @@ -39,28 +39,28 @@ | | | +----------------------------------+ | | | Agent: Claude Code | | -| | Session: Fix auth bug | | +| | Session: Bridge startup | | | | Time: 10:33 AM | | | +----------------------------------+ | | | | Tool: Edit File | -| File: lib/auth/login.dart | +| File: lib/features/startup/... | | Lines: 42-45 | | | | Changes: | | +----------------------------------+ | | | @@ -40,7 +40,8 @@ | | -| | 40 | final config = OAuthConf.. | | -| | 41 | clientId: env.clientId, | | -| | 42 |- callbackUrl: 'http://.. | | -| | 42 |+ callbackUrl: 'https://.. | | -| | 43 |+ redirectValidation: true, | | +| | 40 | final validator = Bridge.. | | +| | 41 | if (url.isEmpty) return... | | +| | 42 |- return wsAllowed(url); | | +| | 42 |+ return wssRequired(url); | | +| | 43 |+ ensureTokenPresent(); | | | +----------------------------------+ | | | | Agent reasoning: | -| "The callback URL must use HTTPS | -| for OAuth security. I'm also | -| adding redirect validation." | +| "The bridge URL must use WSS and | +| the pairing token cannot be empty. | +| I'm tightening startup validation."| | | +---------------------------------------+ | [ Approve ] [ Reject ] | @@ -108,9 +108,9 @@ ``` +---------------------------------------+ | ReCursor now | -| Approval needed: Edit login.dart | -| Claude Code wants to change the | -| OAuth callback URL. | +| Approval needed: Bridge setup edit | +| Claude Code wants to tighten the | +| startup bridge validation. | | | | [ Approve ] [ View ] | +---------------------------------------+ @@ -133,7 +133,7 @@ | Today | | +----------------------------------+ | | | [check] Approved | | -| | Edit lib/auth/login.dart | | +| | Edit bridge_setup_screen.dart | | | | Claude Code * 10:33 AM | | | +----------------------------------+ | | | [check] Approved | | diff --git a/docs/wireframes/08-terminal.md b/docs/wireframes/08-terminal.md index 651c2cc..199c844 100644 --- a/docs/wireframes/08-terminal.md +++ b/docs/wireframes/08-terminal.md @@ -17,8 +17,8 @@ | $ git status | | On branch main | | Changes not staged for commit: | -| modified: lib/auth/login.dart | -| modified: lib/auth/oauth.dart | +| modified: lib/features/startup/bridge_setup_screen.dart | +| modified: lib/features/startup/splash_screen.dart | | | | Untracked files: | | test/auth_test.dart | diff --git a/docs/wireframes/09-agents.md b/docs/wireframes/09-agents.md index 5608e06..ce029aa 100644 --- a/docs/wireframes/09-agents.md +++ b/docs/wireframes/09-agents.md @@ -71,7 +71,7 @@ | | wss://100.78.42.15:3000 | | | +----------------------------------+ | | | -| Auth token: | +| Pairing token: | | +----------------------------------+ | | | ******************************** | | | +----------------------------------+ | diff --git a/docs/wireframes/10-settings.md b/docs/wireframes/10-settings.md index eee1360..392fce1 100644 --- a/docs/wireframes/10-settings.md +++ b/docs/wireframes/10-settings.md @@ -11,12 +11,6 @@ | [=] Settings | +---------------------------------------+ | | -| ACCOUNT | -| +----------------------------------+ | -| | GitHub Account | | -| | @username [>] | | -| +----------------------------------+ | -| | | CONNECTIONS | | +----------------------------------+ | | | My Agents | | @@ -58,7 +52,7 @@ | | Open Source Licenses | | | +----------------------------------+ | | | -| [ Sign Out ] | +| [ Disconnect Bridge ] | | | +---------------------------------------+ | Agent | Repos | Git | Settings | @@ -67,26 +61,25 @@ --- -## 10B. Account +## 10B. Bridge Connection ``` +---------------------------------------+ -| [<] Account | +| [<] Bridge Connection | +---------------------------------------+ | | -| [ avatar image ] | -| @username | +| [ App Icon ] | | | | +----------------------------------+ | -| | Auth method | | -| | GitHub OAuth | | +| | Bridge URL | | +| | wss://100.x.x.x:3000 [>] | | | +----------------------------------+ | -| | Token expires | | -| | March 20, 2026 | | +| | Device paired | | +| | March 20, 2026 | | | +----------------------------------+ | | | -| [ Re-authenticate ] | -| [ Switch Account ] | +| [ Re-pair Bridge ] | +| [ Reset Connection ] | | | +---------------------------------------+ ``` diff --git a/docs/wireframes/11-tablet.md b/docs/wireframes/11-tablet.md index 52e5f1d..e467967 100644 --- a/docs/wireframes/11-tablet.md +++ b/docs/wireframes/11-tablet.md @@ -13,17 +13,17 @@ | | | | CHAT | DIFF / FILE VIEWER | | | | -| +------------------------+ | login.dart | +| +------------------------+ | bridge_setup_screen.dart | | | You 10:32 AM | | | -| | Fix the OAuth redirect | | @@ -40,7 +40,8 @@ | -| | bug in login.dart | | | -| +------------------------+ | 40 | final config = OAu.. | -| | 41 | clientId: env.cli.. | -| +------------------------+ | 42 |- callbackUrl: 'http.. | -| | Claude Code 10:32 AM | | 42 |+ callbackUrl: 'https.. | -| | | | 43 |+ redirectValidation.. | -| | I'll fix the OAuth | | 44 | ); | -| | redirect. The issue | | | +| | Tighten bridge startup | | @@ -40,7 +40,8 @@ | +| | validation flow | | | +| +------------------------+ | 40 | final validator = Br.. | +| | 41 | if (url.isEmpty) re.. | +| +------------------------+ | 42 |- return allowWs(url); | +| | Claude Code 10:32 AM | | 42 |+ return requireWss(url); | +| | | | 43 |+ ensureTokenPresent(); | +| | I'll tighten the | | 44 | ); | +| | bridge startup flow. | | | | | is on line 42... | | | | | | | | | | [View Diff] | | | @@ -52,14 +52,14 @@ | | | | FILE TREE | FILE VIEWER | | | | -| lib/ | login_screen.dart | -| v core/ | lib/features/auth/ * 142 lines | +| lib/ | bridge_setup_screen.dart | +| v core/ | lib/features/startup/ * 196 lines | | v features/ | | -| v auth/ | 1 | import 'package:flu.. | -| > data/ | 2 | import 'package:riv.. | -| > domain/ | 3 | | -| * login_screen.dart | 4 | class LoginScreen e.. | -| auth_provider.dart | 5 | @override | +| v startup/ | 1 | import 'package:flu.. | +| > domain/ | 2 | import 'package:riv.. | +| * bridge_setup_.. | 3 | | +| * splash_screen.dart | 4 | class BridgeSetupScreen.. | +| bridge_startup.. | 5 | @override | | > chat/ | 6 | Widget build(Buil.. | | > repos/ | 7 | return Scaffold( | | > shared/ | 8 | appBar: AppBar( | @@ -90,19 +90,19 @@ | | | | COMMIT | DIFF PREVIEW | | | | -| Message: | login.dart | +| Message: | bridge_setup_screen.dart | | +------------------------+ | | -| | Fix OAuth redirect bug | | @@ -40,7 +40,8 @@ | -| +------------------------+ | 40 | final config = OA.. | -| +------------------------+ | 41 | clientId: env.cl.. | -| | Updated callback URL | | 42 |- callbackUrl: 'ht.. | -| | from http to https... | | 42 |+ callbackUrl: 'ht.. | -| +------------------------+ | 43 |+ redirectValidati.. | +| | Tighten bridge startup | | @@ -40,7 +40,8 @@ | +| +------------------------+ | 40 | final validator = Br.. | +| +------------------------+ | 41 | if (url.isEmpty) re.. | +| | Require WSS and a | | 42 |- return allowWs(url); | +| | pairing token first... | | 42 |+ return requireWss(url); | +| +------------------------+ | 43 |+ ensureTokenPresent(); | | | | -| Files: | oauth.dart | -| [x] M login.dart [>] | | -| [x] M oauth.dart [>]| @@ -12,3 +12,5 @@ | -| [ ] ? auth_test.dart [>]| 12 | validateRedirect(.. | +| Files: | bridge_setup_screen.dart | +| [x] M splash_screen.. [>] | | +| [x] M bridge_setup.. [>]| @@ -12,3 +12,5 @@ | +| [ ] ? startup_test.. [>]| 12 | requireWss(.. | | | 13 |+ if (!uri.isScheme.. | | [ Commit ] | | | | | @@ -131,8 +131,8 @@ | $ git status | | On branch main | | Changes not staged for commit: | -| modified: lib/auth/login.dart | -| modified: lib/auth/oauth.dart | +| modified: lib/features/startup/bridge_setup_screen.dart | +| modified: lib/features/startup/splash_screen.dart | | | | $ _ | +-------------------------------------------------------------------+ diff --git a/docs/wireframes/README.md b/docs/wireframes/README.md index 0190f12..a67893a 100644 --- a/docs/wireframes/README.md +++ b/docs/wireframes/README.md @@ -1,30 +1,33 @@ # ReCursor - UI/UX Wireframes > Modular ASCII wireframes for every screen in the mobile app. -> Each file maps to a feature module. Screens are ordered by user flow. +> Each file maps to a feature module. Screens are ordered by the bridge-first, no-login user flow. --- -## Navigation Architecture +## Navigation Architecture (Bridge-First, No-Login) ``` +------------------+ - | Splash Screen | + | Splash / Restore | + | (No login screen) | +--------+---------+ | - +--------v---------+ - | Login Screen | - +--------+---------+ + +--------------v--------------+ + | Saved Pairing? | + | (Restore or New Pairing) | + +--------------+--------------+ | +--------v---------+ - | Bridge Pairing | + | Bridge Setup | + | (QR Pairing) | +--------+---------+ | +--------------v--------------+ | Main Shell (Bottom Nav) | +-+--------+--------+--------+-+ | | | | - +---v--+ +---v--+ +---v--+ +---v------+ + +---v--+ +---v--+ +---v--+ +---v------+ | Chat | | Repos| | Git | | Settings | +---+--+ +---+--+ +---+--+ +----------+ | | | @@ -34,6 +37,8 @@ +-------+ +-------+ +----------+ ``` +**Key:** No login screen, no user accounts — the app opens directly to bridge pairing restoration or setup. + ## Bottom Navigation Tabs ``` @@ -47,7 +52,7 @@ | File | Screens | Phase | |------|---------|-------| -| [`01-auth.md`](01-auth.md) | Splash, Login, OAuth WebView | 1 | +| [`01-startup.md`](01-startup.md) | Splash, Saved Pairing Restore, Startup Fallback | 1 | | [`02-bridge.md`](02-bridge.md) | QR Pairing, Connection Status | 1 | | [`03-chat.md`](03-chat.md) | Chat, Session List, Streaming, Voice Input | 1, 3 | | [`04-repos.md`](04-repos.md) | Repo List, File Tree, File Viewer | 1 | diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..a4066dc --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,10 @@ +app_identifier("dev.recursor.mobile") + +# iOS +apple_id(ENV["APPLE_ID"]) +itc_team_id(ENV["ITC_TEAM_ID"]) +team_id(ENV["TEAM_ID"]) + +# Android +json_key_file(ENV["SUPPLY_JSON_KEY"]) +package_name("dev.recursor.mobile") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..d5d0a3d --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,81 @@ +fastlane_version "2.220.0" + +default_platform(:ios) + +# ─── iOS ───────────────────────────────────────────────────────────────────── + +platform :ios do + desc "Sync certificates and provisioning profiles (read-only)" + lane :certificates do + match( + type: "appstore", + readonly: true, + app_identifier: "dev.recursor.mobile" + ) + end + + desc "Build release IPA" + lane :build do + match( + type: "appstore", + readonly: true, + app_identifier: "dev.recursor.mobile" + ) + + build_app( + workspace: "apps/mobile/ios/Runner.xcworkspace", + scheme: "Runner", + export_method: "app-store", + output_directory: "apps/mobile/build/ios/iphoneos", + clean: true + ) + end + + desc "Upload to TestFlight" + lane :testflight do + build + upload_to_testflight( + skip_waiting_for_build_processing: true + ) + end + + desc "Submit to App Store" + lane :release do + build + upload_to_app_store( + skip_metadata: false, + skip_screenshots: false, + submit_for_review: false + ) + end +end + +# ─── Android ───────────────────────────────────────────────────────────────── + +platform :android do + desc "Build release App Bundle" + lane :build do + gradle( + task: "bundle", + build_type: "Release", + project_dir: "apps/mobile/android" + ) + end + + desc "Deploy to Play Store internal track" + lane :deploy do + build + upload_to_play_store( + track: "internal", + aab: "apps/mobile/build/app/outputs/bundle/release/app-release.aab" + ) + end + + desc "Promote internal → production" + lane :promote do + upload_to_play_store( + track: "internal", + track_promote_to: "production" + ) + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 0000000..f96d00f --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,4 @@ +git_url(ENV["MATCH_GIT_URL"]) +storage_mode("git") +type("appstore") +app_identifier(["dev.recursor.mobile"]) diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000..1fb97e8 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +# gem "fastlane-plugin-example" diff --git a/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..84c6a50 --- /dev/null +++ b/fastlane/metadata/en-US/description.txt @@ -0,0 +1,20 @@ +ReCursor brings your AI coding agent workflows to mobile. Monitor Claude Code sessions, review and approve tool calls, view code diffs, and interact with AI coding agents — all from your iPhone or Android device. + +FEATURES +• Real-time Claude Code session monitoring via hooks integration +• OpenCode-style tool cards showing exactly what your AI agent is doing +• Syntax-highlighted code diff viewer +• Tool call approval flow — approve, reject, or modify AI actions before they execute +• Parallel Agent SDK sessions for direct AI interaction from mobile +• Session timeline with full event history +• Git status, commits, and branch management +• Embedded terminal with ANSI color support +• Multi-agent support: Claude Code, OpenCode, Aider, Goose +• Offline mode with local SQLite storage and sync on reconnect +• Voice-to-text input +• Secure connection via Tailscale/WireGuard mesh VPN + +GETTING STARTED +ReCursor connects to the ReCursor bridge server running on your development machine. Pair via QR code or manual entry. + +Requires: ReCursor bridge server (open source, runs on macOS/Linux/Windows) diff --git a/fastlane/metadata/en-US/keywords.txt b/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..e35e91e --- /dev/null +++ b/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +AI,coding,agent,Claude,developer,mobile,code review,diff viewer,terminal diff --git a/fastlane/metadata/en-US/name.txt b/fastlane/metadata/en-US/name.txt new file mode 100644 index 0000000..822c469 --- /dev/null +++ b/fastlane/metadata/en-US/name.txt @@ -0,0 +1 @@ +ReCursor diff --git a/fastlane/metadata/en-US/privacy_url.txt b/fastlane/metadata/en-US/privacy_url.txt new file mode 100644 index 0000000..55c3c66 --- /dev/null +++ b/fastlane/metadata/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://recursor.dev/privacy diff --git a/fastlane/metadata/en-US/release_notes.txt b/fastlane/metadata/en-US/release_notes.txt new file mode 100644 index 0000000..ee4955f --- /dev/null +++ b/fastlane/metadata/en-US/release_notes.txt @@ -0,0 +1,10 @@ +Initial release of ReCursor — AI Coding Agent companion for mobile. + +• Claude Code session monitoring via hooks +• Tool card approval flow +• Code diff viewer +• Agent chat with streaming responses +• Git operations +• Terminal access +• Voice input +• Offline mode diff --git a/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..6e6e735 --- /dev/null +++ b/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +AI Coding Agent on Mobile diff --git a/fastlane/metadata/en-US/support_url.txt b/fastlane/metadata/en-US/support_url.txt new file mode 100644 index 0000000..8031d24 --- /dev/null +++ b/fastlane/metadata/en-US/support_url.txt @@ -0,0 +1 @@ +https://github.com/RecursiveDev/ReCursor/issues diff --git a/packages/bridge/.prettierignore b/packages/bridge/.prettierignore new file mode 100644 index 0000000..2d0c064 --- /dev/null +++ b/packages/bridge/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/packages/bridge/.prettierrc.json b/packages/bridge/.prettierrc.json new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/packages/bridge/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/packages/bridge/jest.config.cjs b/packages/bridge/jest.config.cjs new file mode 100644 index 0000000..b47f49b --- /dev/null +++ b/packages/bridge/jest.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], + setupFiles: ["/tests/jest.setup.ts"], + clearMocks: true, +}; diff --git a/packages/bridge/package-lock.json b/packages/bridge/package-lock.json new file mode 100644 index 0000000..d50bdde --- /dev/null +++ b/packages/bridge/package-lock.json @@ -0,0 +1,5767 @@ +{ + "name": "@recursor/bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@recursor/bridge", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.79.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.1", + "simple-git": "^3.27.0", + "uuid": "^10.0.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.0.0", + "@types/supertest": "^7.2.0", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", + "jest": "^29.7.0", + "nodemon": "^3.1.7", + "prettier": "^3.8.1", + "supertest": "^7.2.2", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.79.0.tgz", + "integrity": "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-git": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", + "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 47dfea8..60ab1f4 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -1,15 +1,45 @@ { "name": "@recursor/bridge", "private": true, - "version": "0.0.0", - "description": "ReCursor bridge server (scaffold only).", + "version": "0.1.0", + "description": "ReCursor bridge server", "license": "MIT", "main": "dist/index.js", "scripts": { "build": "tsc -p tsconfig.json", + "check": "npm run format:check && npm run typecheck && npm run test:ci && npm run build", + "dev": "nodemon --exec ts-node src/index.ts", + "format": "prettier --write .", + "format:check": "prettier --check .", + "start": "node dist/index.js", + "test": "jest", + "test:ci": "jest --passWithNoTests --runInBand", "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "@anthropic-ai/sdk": "^0.79.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.1", + "simple-git": "^3.27.0", + "uuid": "^10.0.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.0.0", + "@types/supertest": "^7.2.0", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.13", + "jest": "^29.7.0", + "nodemon": "^3.1.7", + "prettier": "^3.8.1", + "supertest": "^7.2.2", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", "typescript": "^5.6.0" } } diff --git a/packages/bridge/src/agents/agent_runtime.ts b/packages/bridge/src/agents/agent_runtime.ts new file mode 100644 index 0000000..8a76fa6 --- /dev/null +++ b/packages/bridge/src/agents/agent_runtime.ts @@ -0,0 +1,164 @@ +import Anthropic from "@anthropic-ai/sdk"; + +export interface AgentToolDefinition { + name: string; + description: string; + inputSchema: Anthropic.Tool["input_schema"]; +} + +export interface AgentRuntimeTextBlock { + type: "text"; + text: string; +} + +export interface AgentRuntimeToolUseBlock { + type: "tool_use"; + id: string; + name: string; + input: Record; +} + +export interface AgentRuntimeToolResultBlock { + type: "tool_result"; + tool_use_id: string; + content: string; + is_error?: boolean; +} + +export type AgentRuntimeMessageContentBlock = + | AgentRuntimeTextBlock + | AgentRuntimeToolUseBlock + | AgentRuntimeToolResultBlock; + +export interface AgentRuntimeMessage { + role: "user" | "assistant"; + content: string | AgentRuntimeMessageContentBlock[]; +} + +export interface AgentRuntimeTurnRequest { + model: string; + maxTokens: number; + messages: AgentRuntimeMessage[]; + systemPrompt?: string; + tools?: AgentToolDefinition[]; + onTextDelta?: (text: string) => void; +} + +export interface AgentRuntimeTurnResult { + stopReason: string | null; + message: { + role: "assistant"; + content: Array; + }; +} + +export interface AgentRuntime { + runTurn(request: AgentRuntimeTurnRequest): Promise; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export class AnthropicMessageRuntime implements AgentRuntime { + constructor(private client: Anthropic) {} + + async runTurn(request: AgentRuntimeTurnRequest): Promise { + const streamParams: Anthropic.MessageStreamParams = { + model: request.model, + max_tokens: request.maxTokens, + messages: request.messages.map((message) => this.toAnthropicMessage(message)), + stream: true, + }; + + if (request.systemPrompt) { + (streamParams as Record).system = request.systemPrompt; + } + + if (request.tools && request.tools.length > 0) { + (streamParams as Record).tools = request.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, + })); + } + + const stream = this.client.messages.stream(streamParams); + if (request.onTextDelta) { + stream.on("text", (textDelta) => { + request.onTextDelta?.(textDelta); + }); + } + + const finalMessage = await stream.finalMessage(); + + return { + stopReason: finalMessage.stop_reason, + message: { + role: "assistant", + content: finalMessage.content.flatMap((block) => { + const normalized = this.fromAnthropicContentBlock(block); + return normalized ? [normalized] : []; + }), + }, + }; + } + + private toAnthropicMessage(message: AgentRuntimeMessage): Anthropic.MessageParam { + const content = + typeof message.content === "string" + ? message.content + : message.content.map((block) => this.toAnthropicContentBlock(block)); + + return { + role: message.role, + content, + }; + } + + private toAnthropicContentBlock(block: AgentRuntimeMessageContentBlock) { + switch (block.type) { + case "text": + return { + type: "text" as const, + text: block.text, + }; + case "tool_use": + return { + type: "tool_use" as const, + id: block.id, + name: block.name, + input: block.input, + }; + case "tool_result": + return { + type: "tool_result" as const, + tool_use_id: block.tool_use_id, + content: block.content, + is_error: block.is_error, + }; + } + } + + private fromAnthropicContentBlock( + block: Anthropic.ContentBlock, + ): AgentRuntimeTextBlock | AgentRuntimeToolUseBlock | null { + if (block.type === "text") { + return { + type: "text", + text: block.text, + }; + } + + if (block.type === "tool_use") { + return { + type: "tool_use", + id: block.id, + name: block.name, + input: isRecord(block.input) ? block.input : {}, + }; + } + + return null; + } +} diff --git a/packages/bridge/src/agents/agent_sdk_adapter.ts b/packages/bridge/src/agents/agent_sdk_adapter.ts new file mode 100644 index 0000000..f1a76bb --- /dev/null +++ b/packages/bridge/src/agents/agent_sdk_adapter.ts @@ -0,0 +1,152 @@ +import { v4 as uuidv4 } from "uuid"; +import { AgentSessionManager } from "./session_manager"; +import type { ConnectionManager } from "../websocket/connection_manager"; +import type { + ApprovalResponsePayload, + BridgeMessage, + ErrorPayload, + MessagePayload, + SessionEndPayload, + SessionReadyPayload, + SessionStartPayload, + SupportedAgent, +} from "../types"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [AgentSdkAdapter] ${msg}`); +} + +function ts(): string { + return new Date().toISOString(); +} + +function resolveSupportedAgent(agent?: string): SupportedAgent { + if (!agent || agent === "claude-code") { + return "claude-code"; + } + + throw new Error(`Unsupported agent: ${agent}. Only claude-code is currently supported.`); +} + +export class AgentSdkAdapter { + private sessionManager: AgentSessionManager; + private connectionManager: ConnectionManager; + + constructor(sessionManager: AgentSessionManager, connectionManager: ConnectionManager) { + this.sessionManager = sessionManager; + this.connectionManager = connectionManager; + } + + async handleSessionStart( + payload: SessionStartPayload, + clientId: string, + requestId?: string, + ): Promise { + try { + const agent = resolveSupportedAgent(payload.agent); + const shouldResume = payload.resume === true && typeof payload.session_id === "string"; + let sessionId: string; + + if (shouldResume && payload.session_id) { + await this.sessionManager.resumeSession(payload.session_id); + sessionId = payload.session_id; + } else { + sessionId = await this.sessionManager.createSession({ + agent, + sessionId: payload.session_id ?? undefined, + workingDirectory: payload.working_directory, + systemPrompt: payload.system_prompt, + model: payload.model, + }); + } + + this.connectionManager.addSessionToClient(clientId, sessionId); + + const session = this.sessionManager.getSession(sessionId); + if (!session) { + throw new Error(`Session not found after start: ${sessionId}`); + } + + const readyMsg: BridgeMessage = { + type: "session_ready", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: { + session_id: sessionId, + agent: session.agent, + working_directory: session.working_directory, + status: "ready", + model: session.model, + }, + }; + this.connectionManager.sendToClient(clientId, readyMsg); + log(`Session started: ${sessionId} for client ${clientId}`); + } catch (err) { + log(`Failed to start session: ${String(err)}`); + const errorMsg: BridgeMessage = { + type: "error", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: { + code: "BRIDGE_ERROR", + message: String(err), + request_type: "session_start", + recoverable: false, + }, + }; + this.connectionManager.sendToClient(clientId, errorMsg); + } + } + + async handleMessage(payload: MessagePayload, clientId: string): Promise { + try { + await this.sessionManager.sendMessage(payload.session_id, payload.content, clientId); + } catch (err) { + log(`Failed to send message: ${String(err)}`); + const errorMsg: BridgeMessage = { + type: "error", + id: uuidv4(), + timestamp: ts(), + payload: { + code: "AGENT_ERROR", + message: String(err), + request_type: "message", + session_id: payload.session_id, + recoverable: true, + }, + }; + this.connectionManager.sendToClient(clientId, errorMsg); + } + } + + async handleApprovalResponse(payload: ApprovalResponsePayload, clientId: string): Promise { + try { + await this.sessionManager.executeToolCall( + payload.session_id, + payload.tool_call_id, + payload.decision, + payload.modifications, + ); + } catch (err) { + log(`Failed to handle approval response: ${String(err)}`); + const errorMsg: BridgeMessage = { + type: "error", + id: uuidv4(), + timestamp: ts(), + payload: { + code: "TOOL_ERROR", + message: String(err), + request_type: "approval_response", + session_id: payload.session_id, + recoverable: true, + }, + }; + this.connectionManager.sendToClient(clientId, errorMsg); + } + } + + handleSessionEnd(payload: SessionEndPayload): void { + this.sessionManager.closeSession(payload.session_id); + log(`Session ended: ${payload.session_id}`); + } +} diff --git a/packages/bridge/src/agents/session_manager.ts b/packages/bridge/src/agents/session_manager.ts new file mode 100644 index 0000000..4e055b0 --- /dev/null +++ b/packages/bridge/src/agents/session_manager.ts @@ -0,0 +1,401 @@ +import path from "path"; +import Anthropic from "@anthropic-ai/sdk"; +import { v4 as uuidv4 } from "uuid"; +import { config } from "../config"; +import { eventBus } from "../notifications/event_bus"; +import type { + AgentSession, + ApprovalDecision, + ApprovalRequiredPayload, + SessionConfig, + StreamChunkPayload, + StreamEndPayload, + StreamStartPayload, + SupportedAgent, + ToolExecutionResult, +} from "../types"; +import { + AnthropicMessageRuntime, + type AgentRuntime, + type AgentRuntimeMessage, + type AgentRuntimeToolResultBlock, + type AgentRuntimeToolUseBlock, +} from "./agent_runtime"; +import { isWithinAllowedRoot, ToolExecutor } from "./tool_executor"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [AgentSessionManager] ${msg}`); +} + +interface PendingToolResolution { + decision: ApprovalDecision; + params: Record; +} + +interface PendingToolCall { + tool: string; + params: Record; + resolve: (resolution: PendingToolResolution) => void; +} + +interface InternalSession { + meta: AgentSession; + history: AgentRuntimeMessage[]; + systemPrompt?: string; + pendingToolCalls: Map; +} + +function toSupportedAgent(agent?: SupportedAgent): SupportedAgent { + return agent ?? "claude-code"; +} + +function buildSessionTitle(workingDirectory: string): string { + return path.basename(workingDirectory) || "Claude Code session"; +} + +function mapFinishReason(stopReason: string | null): string { + switch (stopReason) { + case "end_turn": + return "stop"; + case "max_tokens": + return "length"; + case "tool_use": + return "tool_call"; + default: + return stopReason ?? "stop"; + } +} + +function mapRiskLevel(tool: string): ApprovalRequiredPayload["risk_level"] { + switch (tool) { + case "run_command": + case "Bash": + return "high"; + case "edit_file": + case "Edit": + return "medium"; + case "read_file": + case "glob": + case "grep": + case "ls": + case "Read": + case "Glob": + case "Grep": + case "LS": + return "low"; + default: + return "medium"; + } +} + +function formatToolResultForModel(tool: string, result: ToolExecutionResult): string { + const parts = [`Tool: ${tool}`, `Success: ${result.success ? "true" : "false"}`]; + + if (result.content) { + parts.push(`Content:\n${result.content}`); + } + + if (result.diff) { + parts.push(`Diff:\n${result.diff}`); + } + + if (result.error) { + parts.push(`Error:\n${result.error}`); + } + + if (typeof result.duration_ms === "number") { + parts.push(`DurationMs: ${result.duration_ms}`); + } + + return parts.join("\n\n"); +} + +export class AgentSessionManager { + private runtime: AgentRuntime; + private toolExecutor: ToolExecutor; + private sessions = new Map(); + + constructor(runtime?: AgentRuntime, toolExecutor?: ToolExecutor) { + this.runtime = + runtime ?? new AnthropicMessageRuntime(new Anthropic({ apiKey: config.ANTHROPIC_API_KEY })); + this.toolExecutor = toolExecutor ?? new ToolExecutor(); + } + + async createSession(sessionConfig: SessionConfig): Promise { + const sessionId = sessionConfig.sessionId ?? uuidv4(); + const agent = toSupportedAgent(sessionConfig.agent); + const workingDirectory = path.resolve( + sessionConfig.workingDirectory ?? config.ALLOWED_PROJECT_ROOT, + ); + + if (!isWithinAllowedRoot(workingDirectory)) { + throw new Error(`Working directory is outside of allowed project root: ${workingDirectory}`); + } + + const meta: AgentSession = { + id: sessionId, + agent, + title: buildSessionTitle(workingDirectory), + model: sessionConfig.model ?? config.AGENT_MODEL, + working_directory: workingDirectory, + created_at: new Date().toISOString(), + status: "idle", + }; + + const internal: InternalSession = { + meta, + history: [], + systemPrompt: sessionConfig.systemPrompt, + pendingToolCalls: new Map(), + }; + + this.sessions.set(sessionId, internal); + log(`Created session: ${sessionId}`); + + eventBus.emitTyped("session-event", { + type: "session_created", + session_id: sessionId, + agent: meta.agent, + model: meta.model, + }); + + return sessionId; + } + + async resumeSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.meta.status = "idle"; + log(`Resumed session: ${sessionId}`); + } + + closeSession(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + session.meta.status = "closed"; + this.sessions.delete(sessionId); + log(`Closed session: ${sessionId}`); + + eventBus.emitTyped("session-event", { + type: "session_closed", + session_id: sessionId, + }); + } + + async sendMessage(sessionId: string, content: string, clientId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + session.meta.status = "active"; + session.history.push({ role: "user", content }); + + const messageId = uuidv4(); + const startPayload: StreamStartPayload = { + session_id: sessionId, + message_id: messageId, + }; + + eventBus.emitTyped("session-event", { + type: "stream_start", + ...startPayload, + client_id: clientId, + }); + + let finishReason = "stop"; + + try { + for (let iteration = 0; iteration < config.AGENT_MAX_ITERATIONS; iteration += 1) { + const turn = await this.runtime.runTurn({ + model: session.meta.model, + maxTokens: 8192, + messages: session.history, + systemPrompt: session.systemPrompt, + tools: this.toolExecutor.getToolDefinitions(), + onTextDelta: (textDelta) => { + const chunkPayload: StreamChunkPayload = { + session_id: sessionId, + message_id: messageId, + content: textDelta, + is_tool_use: false, + }; + eventBus.emitTyped("stream-chunk", chunkPayload); + }, + }); + + session.history.push({ + role: turn.message.role, + content: turn.message.content, + }); + finishReason = mapFinishReason(turn.stopReason); + + const toolCalls = turn.message.content.filter( + (block): block is AgentRuntimeToolUseBlock => block.type === "tool_use", + ); + + if (turn.stopReason !== "tool_use" || toolCalls.length === 0) { + session.meta.status = "idle"; + const endPayload: StreamEndPayload = { + session_id: sessionId, + message_id: messageId, + finish_reason: finishReason, + }; + + eventBus.emitTyped("session-event", { + type: "stream_end", + ...endPayload, + client_id: clientId, + }); + return; + } + + const toolResults: AgentRuntimeToolResultBlock[] = []; + for (const toolCall of toolCalls) { + toolResults.push(await this.executeApprovedTool(sessionId, clientId, session, toolCall)); + } + + session.history.push({ + role: "user", + content: toolResults, + }); + } + + throw new Error( + `Agent tool loop exceeded configured max iterations: ${config.AGENT_MAX_ITERATIONS}`, + ); + } catch (err) { + session.meta.status = "idle"; + log(`Stream error in session ${sessionId}: ${String(err)}`); + throw err; + } + } + + async executeToolCall( + sessionId: string, + toolCallId: string, + decision: ApprovalDecision, + modifications: Record | null, + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + const pending = session.pendingToolCalls.get(toolCallId); + if (!pending) { + throw new Error(`Tool call not found: ${toolCallId}`); + } + + session.pendingToolCalls.delete(toolCallId); + + if (decision === "rejected") { + log(`Tool call rejected: ${toolCallId}`); + eventBus.emitTyped("tool-event", { + type: "tool_rejected", + session_id: sessionId, + tool_call_id: toolCallId, + }); + pending.resolve({ decision, params: pending.params }); + return; + } + + const resolvedParams = + decision === "modified" ? (modifications ?? pending.params) : pending.params; + + log(`Tool call ${decision}: ${toolCallId} (${pending.tool})`); + eventBus.emitTyped("tool-event", { + type: decision === "modified" ? "tool_modified" : "tool_approved", + session_id: sessionId, + tool_call_id: toolCallId, + tool: pending.tool, + params: resolvedParams, + }); + pending.resolve({ decision, params: resolvedParams }); + } + + getSession(sessionId: string): AgentSession | undefined { + return this.sessions.get(sessionId)?.meta; + } + + getActiveSessions(): AgentSession[] { + return Array.from(this.sessions.values()) + .filter((session) => session.meta.status !== "closed") + .map((session) => session.meta); + } + + private async executeApprovedTool( + sessionId: string, + clientId: string, + session: InternalSession, + toolCall: AgentRuntimeToolUseBlock, + ): Promise { + const approval = await new Promise((resolve) => { + session.pendingToolCalls.set(toolCall.id, { + tool: toolCall.name, + params: toolCall.input, + resolve, + }); + + const approvalPayload: ApprovalRequiredPayload = { + session_id: sessionId, + tool_call_id: toolCall.id, + tool: toolCall.name, + params: toolCall.input, + description: `Approval required for ${toolCall.name}`, + risk_level: mapRiskLevel(toolCall.name), + source: "agent_sdk", + }; + + eventBus.emitTyped("tool-event", { + type: "approval_required", + ...approvalPayload, + client_id: clientId, + }); + }); + + if (approval.decision === "rejected") { + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: `Tool execution rejected by user for ${toolCall.name}.`, + is_error: true, + }; + } + + const execution = await this.toolExecutor.execute( + toolCall.name, + approval.params, + session.meta.working_directory, + ); + + const result: ToolExecutionResult = { + success: execution.success, + content: execution.content, + diff: execution.diff, + error: execution.error, + duration_ms: execution.durationMs, + }; + + eventBus.emitTyped("tool-event", { + type: "tool_result", + session_id: sessionId, + tool_call_id: toolCall.id, + tool: toolCall.name, + result, + }); + + return { + type: "tool_result", + tool_use_id: toolCall.id, + content: formatToolResultForModel(toolCall.name, result), + is_error: !result.success, + }; + } +} diff --git a/packages/bridge/src/agents/tool_executor.ts b/packages/bridge/src/agents/tool_executor.ts new file mode 100644 index 0000000..281b79f --- /dev/null +++ b/packages/bridge/src/agents/tool_executor.ts @@ -0,0 +1,493 @@ +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import { promisify } from "util"; +import { config } from "../config"; +import type { ToolResult } from "../types"; +import type { AgentToolDefinition } from "./agent_runtime"; + +const readFileAsync = promisify(fs.readFile); +const writeFileAsync = promisify(fs.writeFile); +const readdirAsync = promisify(fs.readdir); +const statAsync = promisify(fs.stat); + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [ToolExecutor] ${msg}`); +} + +const ALLOWED_COMMANDS = ["git", "flutter", "npm", "node", "dart"]; + +function normalizeToolName(tool: string): string { + switch (tool) { + case "Read": + return "read_file"; + case "Edit": + return "edit_file"; + case "Bash": + case "bash_command": + return "run_command"; + case "Glob": + return "glob"; + case "Grep": + return "grep"; + case "LS": + case "list_files": + return "ls"; + default: + return tool; + } +} + +export function isWithinAllowedRoot(filePath: string): boolean { + const resolved = path.resolve(filePath); + const allowed = path.resolve(config.ALLOWED_PROJECT_ROOT); + return resolved.startsWith(allowed + path.sep) || resolved === allowed; +} + +function resolveWithinRoot(filePath: string, workingDir: string): string { + return path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(workingDir, filePath); +} + +export class ToolExecutor { + getToolDefinitions(): AgentToolDefinition[] { + return [ + { + name: "read_file", + description: "Read the UTF-8 contents of a file within the allowed project root.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + path: { + type: "string", + description: + "File path to read, absolute or relative to the session working directory.", + }, + }, + required: ["path"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in a file within the allowed project root.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string" }, + old_string: { type: "string" }, + new_string: { type: "string" }, + }, + required: ["path", "old_string", "new_string"], + }, + }, + { + name: "run_command", + description: + "Run an allowlisted command inside the session working directory. Allowed commands: git, flutter, npm, node, dart.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string" }, + description: { type: "string" }, + }, + required: ["command"], + }, + }, + { + name: "glob", + description: "List files matching a glob pattern within the allowed project root.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + pattern: { type: "string" }, + base_dir: { type: "string" }, + }, + required: ["pattern"], + }, + }, + { + name: "grep", + description: + "Search file contents for a regular expression within the allowed project root.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + pattern: { type: "string" }, + path: { type: "string" }, + }, + required: ["pattern"], + }, + }, + { + name: "ls", + description: "List files and directories within the allowed project root.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string" }, + }, + }, + }, + ]; + } + + async execute( + tool: string, + params: Record, + workingDir: string, + ): Promise { + const start = Date.now(); + const normalizedTool = normalizeToolName(tool); + + if (!isWithinAllowedRoot(workingDir)) { + return { + success: false, + content: "", + error: `Working directory is outside of allowed project root: ${workingDir}`, + durationMs: Date.now() - start, + }; + } + + try { + let result: ToolResult; + switch (normalizedTool) { + case "read_file": + result = await this.readFile(params, workingDir, start); + break; + case "edit_file": + result = await this.editFile(params, workingDir, start); + break; + case "run_command": + result = await this.runCommand(params, workingDir, start); + break; + case "glob": + result = await this.glob(params, workingDir, start); + break; + case "grep": + result = await this.grep(params, workingDir, start); + break; + case "ls": + result = await this.listFiles(params, workingDir, start); + break; + default: + result = { + success: false, + content: "", + error: `Unknown tool: ${tool}`, + durationMs: Date.now() - start, + }; + } + return result; + } catch (err) { + log(`Tool ${normalizedTool} failed: ${String(err)}`); + return { + success: false, + content: "", + error: String(err), + durationMs: Date.now() - start, + }; + } + } + + private async readFile( + params: Record, + workingDir: string, + start: number, + ): Promise { + const filePath = String(params["path"] ?? ""); + const resolved = resolveWithinRoot(filePath, workingDir); + + if (!isWithinAllowedRoot(resolved)) { + return { + success: false, + content: "", + error: "Path outside allowed root", + durationMs: Date.now() - start, + }; + } + + const content = await readFileAsync(resolved, "utf8"); + return { success: true, content, durationMs: Date.now() - start }; + } + + private async editFile( + params: Record, + workingDir: string, + start: number, + ): Promise { + const filePath = String(params["path"] ?? ""); + const oldStr = String(params["old_string"] ?? ""); + const newStr = String(params["new_string"] ?? ""); + const resolved = resolveWithinRoot(filePath, workingDir); + + if (!isWithinAllowedRoot(resolved)) { + return { + success: false, + content: "", + error: "Path outside allowed root", + durationMs: Date.now() - start, + }; + } + + const original = await readFileAsync(resolved, "utf8"); + if (!original.includes(oldStr)) { + return { + success: false, + content: "", + error: "old_string not found in file", + durationMs: Date.now() - start, + }; + } + + const updated = original.replace(oldStr, newStr); + await writeFileAsync(resolved, updated, "utf8"); + return { + success: true, + content: "File updated successfully", + durationMs: Date.now() - start, + diff: `--- ${filePath}\n+++ ${filePath}\n- ${oldStr}\n+ ${newStr}`, + }; + } + + private runCommand( + params: Record, + workingDir: string, + start: number, + ): Promise { + return new Promise((resolve) => { + const command = String(params["command"] ?? ""); + const parts = command + .trim() + .split(/\s+/) + .filter((part) => part.length > 0); + const executable = parts[0]; + + if (!executable) { + resolve({ + success: false, + content: "", + error: "Command is required", + durationMs: Date.now() - start, + }); + return; + } + + if (!ALLOWED_COMMANDS.includes(executable)) { + resolve({ + success: false, + content: "", + error: `Command not allowed: ${executable}. Allowed: ${ALLOWED_COMMANDS.join(", ")}`, + durationMs: Date.now() - start, + }); + return; + } + + const child = spawn(executable, parts.slice(1), { + cwd: workingDir, + env: process.env, + shell: false, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + + child.on("close", (code) => { + const success = code === 0; + resolve({ + success, + content: stdout, + error: success ? undefined : stderr || `Exit code: ${code}`, + durationMs: Date.now() - start, + }); + }); + + child.on("error", (err) => { + resolve({ + success: false, + content: "", + error: String(err), + durationMs: Date.now() - start, + }); + }); + }); + } + + private async glob( + params: Record, + workingDir: string, + start: number, + ): Promise { + const pattern = String(params["pattern"] ?? "**/*"); + const baseDir = resolveWithinRoot(String(params["base_dir"] ?? "."), workingDir); + + if (!isWithinAllowedRoot(baseDir)) { + return { + success: false, + content: "", + error: "Path outside allowed root", + durationMs: Date.now() - start, + }; + } + + const results: string[] = []; + await this.walkGlob(baseDir, baseDir, pattern, results); + return { success: true, content: results.join("\n"), durationMs: Date.now() - start }; + } + + private async walkGlob( + base: string, + current: string, + pattern: string, + results: string[], + ): Promise { + let entries: string[]; + try { + entries = await readdirAsync(current); + } catch { + return; + } + + for (const entry of entries) { + const full = path.join(current, entry); + const rel = path.relative(base, full); + + let stat; + try { + stat = await statAsync(full); + } catch { + continue; + } + + if (stat.isDirectory()) { + await this.walkGlob(base, full, pattern, results); + } else if (this.matchGlob(rel, pattern)) { + results.push(rel); + } + } + } + + private matchGlob(filePath: string, pattern: string): boolean { + const regexStr = pattern + .replace(/\./g, "\\.") + .replace(/\*\*/g, "DOUBLESTAR") + .replace(/\*/g, "[^/]*") + .replace(/DOUBLESTAR/g, ".*"); + const regex = new RegExp(`^${regexStr}$`); + return regex.test(filePath); + } + + private async grep( + params: Record, + workingDir: string, + start: number, + ): Promise { + const searchPattern = String(params["pattern"] ?? ""); + const searchPath = resolveWithinRoot(String(params["path"] ?? "."), workingDir); + + if (!isWithinAllowedRoot(searchPath)) { + return { + success: false, + content: "", + error: "Path outside allowed root", + durationMs: Date.now() - start, + }; + } + + const results: string[] = []; + await this.grepDirectory(searchPath, searchPattern, results); + return { success: true, content: results.join("\n"), durationMs: Date.now() - start }; + } + + private async grepDirectory( + searchPath: string, + pattern: string, + results: string[], + ): Promise { + let stat; + try { + stat = await statAsync(searchPath); + } catch { + return; + } + + if (stat.isFile()) { + await this.grepFile(searchPath, pattern, results); + return; + } + + if (stat.isDirectory()) { + let entries: string[]; + try { + entries = await readdirAsync(searchPath); + } catch { + return; + } + for (const entry of entries) { + await this.grepDirectory(path.join(searchPath, entry), pattern, results); + } + } + } + + private async grepFile(filePath: string, pattern: string, results: string[]): Promise { + let content: string; + try { + content = await readFileAsync(filePath, "utf8"); + } catch { + return; + } + + const regex = new RegExp(pattern, "gm"); + const lines = content.split("\n"); + lines.forEach((line, i) => { + if (regex.test(line)) { + results.push(`${filePath}:${i + 1}: ${line}`); + } + regex.lastIndex = 0; + }); + } + + private async listFiles( + params: Record, + workingDir: string, + start: number, + ): Promise { + const dirPath = resolveWithinRoot(String(params["path"] ?? "."), workingDir); + + if (!isWithinAllowedRoot(dirPath)) { + return { + success: false, + content: "", + error: "Path outside allowed root", + durationMs: Date.now() - start, + }; + } + + const entries = await readdirAsync(dirPath); + const details: string[] = []; + + for (const entry of entries) { + const full = path.join(dirPath, entry); + try { + const stat = await statAsync(full); + const type = stat.isDirectory() ? "dir" : "file"; + details.push(`${type}\t${entry}`); + } catch { + details.push(`unknown\t${entry}`); + } + } + + return { success: true, content: details.join("\n"), durationMs: Date.now() - start }; + } +} diff --git a/packages/bridge/src/auth/rate_limiter.ts b/packages/bridge/src/auth/rate_limiter.ts new file mode 100644 index 0000000..2416e45 --- /dev/null +++ b/packages/bridge/src/auth/rate_limiter.ts @@ -0,0 +1,45 @@ +import type { Request, Response, NextFunction } from "express"; + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +const WINDOW_MS = 60 * 1000; // 1 minute +const MAX_REQUESTS = 60; + +const store = new Map(); + +function getClientIp(req: Request): string { + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string") { + return forwarded.split(",")[0].trim(); + } + return req.socket.remoteAddress ?? "unknown"; +} + +export function rateLimiter(req: Request, res: Response, next: NextFunction): void { + const ip = getClientIp(req); + const now = Date.now(); + + const entry = store.get(ip); + + if (!entry || now - entry.windowStart >= WINDOW_MS) { + store.set(ip, { count: 1, windowStart: now }); + next(); + return; + } + + if (entry.count >= MAX_REQUESTS) { + const retryAfter = Math.ceil((WINDOW_MS - (now - entry.windowStart)) / 1000); + res.setHeader("Retry-After", retryAfter); + res.status(429).json({ + error: "Too Many Requests", + message: `Rate limit exceeded. Try again in ${retryAfter}s`, + }); + return; + } + + entry.count += 1; + next(); +} diff --git a/packages/bridge/src/auth/token_validator.ts b/packages/bridge/src/auth/token_validator.ts new file mode 100644 index 0000000..1791e9a --- /dev/null +++ b/packages/bridge/src/auth/token_validator.ts @@ -0,0 +1,28 @@ +import type { Request, Response, NextFunction } from "express"; +import { config } from "../config"; + +function extractBearerToken(req: Request): string | null { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith("Bearer ")) { + return null; + } + return auth.slice(7); +} + +export function validateBridgeToken(req: Request, res: Response, next: NextFunction): void { + const token = extractBearerToken(req); + if (!token || token !== config.BRIDGE_TOKEN) { + res.status(401).json({ error: "Unauthorized", message: "Invalid or missing bridge token" }); + return; + } + next(); +} + +export function validateHookToken(req: Request, res: Response, next: NextFunction): void { + const token = extractBearerToken(req); + if (!token || token !== config.HOOK_TOKEN) { + res.status(401).json({ error: "Unauthorized", message: "Invalid or missing hook token" }); + return; + } + next(); +} diff --git a/packages/bridge/src/config.ts b/packages/bridge/src/config.ts new file mode 100644 index 0000000..fe4ff77 --- /dev/null +++ b/packages/bridge/src/config.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const configSchema = z.object({ + PORT: z + .string() + .default("3000") + .transform((v) => parseInt(v, 10)), + BRIDGE_TOKEN: z.string().min(1, "BRIDGE_TOKEN is required"), + HOOK_TOKEN: z.string().min(1, "HOOK_TOKEN is required"), + ANTHROPIC_API_KEY: z.string().min(1, "ANTHROPIC_API_KEY is required"), + AGENT_MODEL: z.string().default("claude-opus-4-6"), + AGENT_MAX_ITERATIONS: z + .string() + .default("25") + .transform((v) => parseInt(v, 10)), + ALLOWED_PROJECT_ROOT: z.string().min(1, "ALLOWED_PROJECT_ROOT is required"), +}); + +function loadConfig() { + const result = configSchema.safeParse(process.env); + if (!result.success) { + const messages = result.error.errors + .map((e) => ` ${e.path.join(".")}: ${e.message}`) + .join("\n"); + throw new Error(`Configuration error:\n${messages}`); + } + return result.data; +} + +export const config = loadConfig(); + +export type Config = typeof config; diff --git a/packages/bridge/src/files/file_service.ts b/packages/bridge/src/files/file_service.ts new file mode 100644 index 0000000..8b3ffe9 --- /dev/null +++ b/packages/bridge/src/files/file_service.ts @@ -0,0 +1,125 @@ +import fs from "fs"; +import path from "path"; +import { promisify } from "util"; + +const readdirAsync = promisify(fs.readdir); +const statAsync = promisify(fs.stat); +const readFileAsync = promisify(fs.readFile); + +export interface FileEntry { + name: string; + type: "file" | "directory"; + size?: number; + modifiedAt?: string; +} + +export interface FileListResult { + entries: FileEntry[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; +} + +export interface FileReadResult { + content: string; + totalLines: number; + offset: number; + limit: number; + hasMore: boolean; +} + +const DEFAULT_LIST_LIMIT = 100; +const DEFAULT_READ_LIMIT = 500; + +export class FileService { + constructor(private allowedRoot: string) {} + + async listDirectory( + dirPath: string, + options: { + offset?: number; + limit?: number; + includeHidden?: boolean; + } = {}, + ): Promise { + const resolved = path.resolve(dirPath); + this.validatePath(resolved); + + const { offset = 0, limit = DEFAULT_LIST_LIMIT, includeHidden = false } = options; + + const names = await readdirAsync(resolved); + + const allEntries: FileEntry[] = []; + for (const name of names) { + if (!includeHidden && name.startsWith(".")) { + continue; + } + const full = path.join(resolved, name); + try { + const stat = await statAsync(full); + allEntries.push({ + name, + type: stat.isDirectory() ? "directory" : "file", + size: stat.isFile() ? stat.size : undefined, + modifiedAt: stat.mtime.toISOString(), + }); + } catch { + allEntries.push({ name, type: "file" }); + } + } + + // Sort: directories first, then files, both alphabetically + allEntries.sort((a, b) => { + if (a.type === b.type) { + return a.name.localeCompare(b.name); + } + return a.type === "directory" ? -1 : 1; + }); + + const total = allEntries.length; + const page = allEntries.slice(offset, offset + limit); + + return { + entries: page, + total, + offset, + limit, + hasMore: offset + limit < total, + }; + } + + async readFile( + filePath: string, + options: { + offset?: number; + limit?: number; + } = {}, + ): Promise { + const resolved = path.resolve(filePath); + this.validatePath(resolved); + + const { offset = 0, limit = DEFAULT_READ_LIMIT } = options; + + const raw = await readFileAsync(resolved, "utf8"); + const allLines = raw.split("\n"); + const totalLines = allLines.length; + + const page = allLines.slice(offset, offset + limit); + + return { + content: page.join("\n"), + totalLines, + offset, + limit, + hasMore: offset + limit < totalLines, + }; + } + + private validatePath(p: string): void { + const resolvedRoot = path.resolve(this.allowedRoot); + if (!p.startsWith(resolvedRoot + path.sep) && p !== resolvedRoot) { + throw new Error(`Access denied: path "${p}" is outside the allowed root "${resolvedRoot}"`); + } + } +} diff --git a/packages/bridge/src/git/diff_parser.ts b/packages/bridge/src/git/diff_parser.ts new file mode 100644 index 0000000..09fe638 --- /dev/null +++ b/packages/bridge/src/git/diff_parser.ts @@ -0,0 +1,164 @@ +import type { DiffFile, DiffHunk, DiffLine } from "../types"; + +const FILE_HEADER_RE = /^diff --git a\/(.*) b\/(.*)$/; +const OLD_FILE_RE = /^--- (?:a\/(.*)|\/(dev\/null))$/; +const NEW_FILE_RE = /^\+\+\+ (?:b\/(.*)|\/(dev\/null))$/; +const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/; + +function determineFileStatus( + isNew: boolean, + isDeleted: boolean, + isRenamed: boolean, +): DiffFile["status"] { + if (isNew) { + return "added"; + } + if (isDeleted) { + return "deleted"; + } + if (isRenamed) { + return "renamed"; + } + return "modified"; +} + +export function parseDiff(rawDiff: string): DiffFile[] { + const files: DiffFile[] = []; + if (!rawDiff || rawDiff.trim().length === 0) { + return files; + } + + const lines = rawDiff.split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + const fileHeaderMatch = FILE_HEADER_RE.exec(line); + if (!fileHeaderMatch) { + i++; + continue; + } + + const oldPathRaw = fileHeaderMatch[1]; + const newPathRaw = fileHeaderMatch[2]; + i++; + + let oldPath = oldPathRaw; + let newPath = newPathRaw; + let isNew = false; + let isDeleted = false; + let isRenamed = oldPath !== newPath; + + while (i < lines.length && lines[i].startsWith("index ")) { + i++; + } + + if (i < lines.length) { + const oldMatch = OLD_FILE_RE.exec(lines[i]); + if (oldMatch) { + if (lines[i] === "--- /dev/null") { + isNew = true; + } else if (oldMatch[1]) { + oldPath = oldMatch[1]; + } + i++; + } + } + + if (i < lines.length) { + const newMatch = NEW_FILE_RE.exec(lines[i]); + if (newMatch) { + if (lines[i] === "+++ /dev/null") { + isDeleted = true; + } else if (newMatch[1]) { + newPath = newMatch[1]; + } + i++; + } + } + + const hunks: DiffHunk[] = []; + let additions = 0; + let deletions = 0; + + while (i < lines.length) { + const hunkLine = lines[i]; + + if (FILE_HEADER_RE.test(hunkLine)) { + break; + } + + const hunkMatch = HUNK_HEADER_RE.exec(hunkLine); + if (!hunkMatch) { + i++; + continue; + } + + const oldStart = parseInt(hunkMatch[1], 10); + const oldLines = hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1; + const newStart = parseInt(hunkMatch[3], 10); + const newLines = hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1; + const header = hunkLine; + i++; + + const diffLines: DiffLine[] = []; + let oldLineNum = oldStart; + let newLineNum = newStart; + + while (i < lines.length) { + const diffLine = lines[i]; + + if (FILE_HEADER_RE.test(diffLine) || HUNK_HEADER_RE.test(diffLine)) { + break; + } + + if (diffLine.startsWith("+")) { + additions++; + diffLines.push({ + type: "added", + content: diffLine.slice(1), + new_line_number: newLineNum++, + }); + } else if (diffLine.startsWith("-")) { + deletions++; + diffLines.push({ + type: "removed", + content: diffLine.slice(1), + old_line_number: oldLineNum++, + }); + } else if (diffLine.startsWith(" ") || diffLine === "") { + diffLines.push({ + type: "context", + content: diffLine.startsWith(" ") ? diffLine.slice(1) : diffLine, + old_line_number: oldLineNum++, + new_line_number: newLineNum++, + }); + } + + i++; + } + + hunks.push({ + old_start: oldStart, + old_lines: oldLines, + new_start: newStart, + new_lines: newLines, + header, + lines: diffLines, + }); + } + + files.push({ + path: isDeleted ? oldPath : newPath, + old_path: oldPath, + new_path: newPath, + status: determineFileStatus(isNew, isDeleted, isRenamed), + additions, + deletions, + hunks, + }); + } + + return files; +} diff --git a/packages/bridge/src/git/git_service.ts b/packages/bridge/src/git/git_service.ts new file mode 100644 index 0000000..5941f7f --- /dev/null +++ b/packages/bridge/src/git/git_service.ts @@ -0,0 +1,111 @@ +import simpleGit, { type SimpleGit, type StatusResult } from "simple-git"; +import { parseDiff } from "./diff_parser"; +import type { GitStatusPayload, GitFileChange, DiffFile, GitBranch } from "../types"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [GitService] ${msg}`); +} + +function mapStatusCode(code: string, staged: boolean): GitFileChange["status"] { + switch (code) { + case "A": + return "added"; + case "M": + return "modified"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "?": + return "untracked"; + default: + return "unknown"; + } +} + +export class GitService { + private git: SimpleGit; + private workingDirectory: string; + + constructor(workingDirectory: string) { + this.workingDirectory = workingDirectory; + this.git = simpleGit(workingDirectory); + } + + async getStatus(): Promise { + const status: StatusResult = await this.git.status(); + + const changes: GitFileChange[] = []; + + for (const f of status.files) { + const isStaged = f.index !== " " && f.index !== "?"; + changes.push({ + path: f.path, + status: mapStatusCode(isStaged ? f.index : f.working_dir, isStaged), + staged: isStaged, + }); + } + + return { + branch: status.current ?? "HEAD", + ahead: status.ahead, + behind: status.behind, + is_clean: status.isClean(), + changes, + }; + } + + async getDiff(files?: string[], cached?: boolean): Promise { + const args: string[] = []; + if (cached) args.push("--cached"); + if (files && files.length > 0) { + args.push("--"); + args.push(...files); + } + + const rawDiff = await this.git.diff(args); + return parseDiff(rawDiff); + } + + async commit(message: string, files?: string[]): Promise { + if (files && files.length > 0) { + await this.git.add(files); + } else { + await this.git.add("."); + } + await this.git.commit(message); + log(`Committed: ${message}`); + } + + async getBranches(): Promise { + const summary = await this.git.branch(["-a"]); + const branches: GitBranch[] = []; + + for (const [name, b] of Object.entries(summary.branches)) { + branches.push({ + name: b.name, + is_current: b.current, + is_remote: name.startsWith("remotes/"), + }); + } + + return branches; + } + + async checkout(branch: string): Promise { + await this.git.checkout(branch); + log(`Checked out: ${branch}`); + } + + async pull(): Promise { + await this.git.pull(); + log("Pulled latest changes"); + } + + async push(): Promise { + await this.git.push(); + log("Pushed changes"); + } +} diff --git a/packages/bridge/src/hooks/event_queue.ts b/packages/bridge/src/hooks/event_queue.ts new file mode 100644 index 0000000..51174e0 --- /dev/null +++ b/packages/bridge/src/hooks/event_queue.ts @@ -0,0 +1,65 @@ +import type { BridgeMessage } from "../types"; + +const MAX_QUEUE_SIZE = 1000; + +interface QueuedMessage { + message: BridgeMessage; + sessionId?: string; + notificationId?: string; +} + +export class EventQueue { + private queue: QueuedMessage[] = []; + + enqueue( + message: BridgeMessage, + options: { sessionId?: string; notificationId?: string } = {}, + ): void { + if (this.queue.length >= MAX_QUEUE_SIZE) { + this.queue.shift(); + } + + this.queue.push({ + message, + sessionId: options.sessionId, + notificationId: options.notificationId, + }); + } + + replay(sessionId?: string): BridgeMessage[] { + if (!sessionId) { + return this.queue.map((entry) => entry.message); + } + + return this.queue + .filter((entry) => entry.sessionId === sessionId) + .map((entry) => entry.message); + } + + acknowledgeNotifications(notificationIds: string[]): void { + if (notificationIds.length === 0) { + return; + } + + const acknowledged = new Set(notificationIds); + this.queue = this.queue.filter((entry) => { + if (!entry.notificationId) { + return true; + } + return !acknowledged.has(entry.notificationId); + }); + } + + clear(sessionId?: string): void { + if (!sessionId) { + this.queue = []; + return; + } + + this.queue = this.queue.filter((entry) => entry.sessionId !== sessionId); + } + + size(): number { + return this.queue.length; + } +} diff --git a/packages/bridge/src/hooks/protocol_mapper.ts b/packages/bridge/src/hooks/protocol_mapper.ts new file mode 100644 index 0000000..c57ecf8 --- /dev/null +++ b/packages/bridge/src/hooks/protocol_mapper.ts @@ -0,0 +1,172 @@ +import { v4 as uuidv4 } from "uuid"; +import type { + BridgeMessage, + ClaudeEventPayload, + ApprovalRequiredPayload, + HookEvent, + NotificationPayload, + RiskLevel, + ToolExecutionResult, + ToolResultPayload, +} from "../types"; + +function timestamp(): string { + return new Date().toISOString(); +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function asRiskLevel(value: unknown): RiskLevel { + if (value === "low" || value === "medium" || value === "high" || value === "critical") { + return value; + } + return "medium"; +} + +function extractTool(payload: Record): string { + return asString(payload.tool) ?? asString(payload.tool_name) ?? "unknown_tool"; +} + +function extractToolParams(payload: Record): Record { + return asRecord(payload.params) ?? asRecord(payload.tool_input) ?? {}; +} + +function extractToolCallId(payload: Record): string { + return asString(payload.tool_call_id) ?? uuidv4(); +} + +function createClaudeEventMessage(event: HookEvent): BridgeMessage { + return { + type: "claude_event", + id: uuidv4(), + timestamp: timestamp(), + payload: { + event_type: event.event_type, + session_id: event.session_id, + timestamp: event.timestamp, + payload: event.payload, + }, + }; +} + +function createApprovalRequiredMessage( + event: HookEvent, +): BridgeMessage | null { + if (event.event_type !== "PreToolUse") { + return null; + } + + const payload = event.payload; + const tool = extractTool(payload); + const params = extractToolParams(payload); + const description = + asString(payload.description) ?? asString(payload.message) ?? `Approval required for ${tool}`; + + return { + type: "approval_required", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: event.session_id, + tool_call_id: extractToolCallId(payload), + tool, + params, + description, + risk_level: asRiskLevel(payload.risk_level), + source: "hooks", + }, + }; +} + +function createToolResultMessage(event: HookEvent): BridgeMessage | null { + if (event.event_type !== "PostToolUse") { + return null; + } + + const payload = event.payload; + const resultPayload = asRecord(payload.result); + const result: ToolExecutionResult = { + success: asBoolean(resultPayload?.success) ?? true, + content: + asString(resultPayload?.content) ?? + asString(payload.content) ?? + JSON.stringify(resultPayload ?? payload), + diff: asString(resultPayload?.diff), + error: asString(resultPayload?.error), + duration_ms: + typeof payload.duration_ms === "number" + ? payload.duration_ms + : typeof resultPayload?.duration_ms === "number" + ? resultPayload.duration_ms + : undefined, + }; + + return { + type: "tool_result", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: event.session_id, + tool_call_id: extractToolCallId(payload), + tool: extractTool(payload), + result, + }, + }; +} + +function createNotificationMessage(event: HookEvent): BridgeMessage | null { + if (event.event_type !== "Notification") { + return null; + } + + const payload = event.payload; + const notificationId = uuidv4(); + const level = asString(payload.level); + const priority = level === "error" || level === "warning" ? "high" : "normal"; + + return { + type: "notification", + id: notificationId, + timestamp: timestamp(), + payload: { + notification_id: notificationId, + session_id: event.session_id, + notification_type: asString(payload.notification_type) ?? "agent_idle", + title: asString(payload.title) ?? "Claude Code notification", + body: asString(payload.body) ?? asString(payload.message) ?? JSON.stringify(payload), + priority, + data: asRecord(payload.data) ?? asRecord(payload.metadata) ?? undefined, + }, + }; +} + +export function buildHookProtocolMessages(event: HookEvent): BridgeMessage[] { + const messages: BridgeMessage[] = [createClaudeEventMessage(event)]; + + const approvalRequiredMessage = createApprovalRequiredMessage(event); + if (approvalRequiredMessage) { + messages.push(approvalRequiredMessage); + } + + const toolResultMessage = createToolResultMessage(event); + if (toolResultMessage) { + messages.push(toolResultMessage); + } + + const notificationMessage = createNotificationMessage(event); + if (notificationMessage) { + messages.push(notificationMessage); + } + + return messages; +} diff --git a/packages/bridge/src/hooks/receiver.ts b/packages/bridge/src/hooks/receiver.ts new file mode 100644 index 0000000..cf389a2 --- /dev/null +++ b/packages/bridge/src/hooks/receiver.ts @@ -0,0 +1,52 @@ +import { Router } from "express"; +import { validateHookToken } from "../auth/token_validator"; +import { validateHookEvent } from "./validator"; +import { EventQueue } from "./event_queue"; +import type { ConnectionManager } from "../websocket/connection_manager"; +import type { HookEvent } from "../types"; +import { buildHookProtocolMessages } from "./protocol_mapper"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [HookReceiver] ${msg}`); +} + +export function createHooksRouter( + eventQueue: EventQueue, + connectionManager: ConnectionManager, +): Router { + const router = Router(); + + router.post("/event", validateHookToken, (req, res) => { + const body: unknown = req.body; + + if (!validateHookEvent(body)) { + res.status(400).json({ error: "Bad Request", message: "Invalid hook event shape" }); + return; + } + + const event: HookEvent = body; + log(`Received event: ${event.event_type} (session=${event.session_id})`); + + const messages = buildHookProtocolMessages(event); + for (const message of messages) { + const notificationId = + message.type === "notification" && + typeof message.payload === "object" && + message.payload !== null && + "notification_id" in message.payload && + typeof (message.payload as { notification_id?: unknown }).notification_id === "string" + ? ((message.payload as { notification_id: string }).notification_id as string) + : undefined; + + eventQueue.enqueue(message, { + sessionId: event.session_id, + notificationId, + }); + connectionManager.broadcast(message); + } + + res.status(200).json({ received: true }); + }); + + return router; +} diff --git a/packages/bridge/src/hooks/validator.ts b/packages/bridge/src/hooks/validator.ts new file mode 100644 index 0000000..92e1b3a --- /dev/null +++ b/packages/bridge/src/hooks/validator.ts @@ -0,0 +1,62 @@ +import type { HookEvent } from "../types"; + +const MAX_EVENT_AGE_MS = 5 * 60 * 1000; +const MAX_FUTURE_SKEW_MS = 30 * 1000; + +export const VALID_EVENT_TYPES: string[] = [ + "SessionStart", + "SessionEnd", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Notification", + "Stop", + "SubagentStop", + "PreCompact", +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isValidTimestamp(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0) { + return false; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return false; + } + + const age = Date.now() - timestamp; + return age <= MAX_EVENT_AGE_MS && age >= -MAX_FUTURE_SKEW_MS; +} + +export function validateHookEvent(event: unknown): event is HookEvent { + if (!isRecord(event)) { + return false; + } + + if ( + typeof event.event_type !== "string" || + event.event_type.length === 0 || + !VALID_EVENT_TYPES.includes(event.event_type) + ) { + return false; + } + + if (typeof event.session_id !== "string" || event.session_id.length === 0) { + return false; + } + + if (!isValidTimestamp(event.timestamp)) { + return false; + } + + if (!isRecord(event.payload)) { + return false; + } + + return true; +} diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 0000000..9ee6421 --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,7 @@ +import "dotenv/config"; +import { startServer } from "./server"; + +startServer().catch((err) => { + console.error(`[${new Date().toISOString()}] [Fatal] Failed to start server:`, err); + process.exit(1); +}); diff --git a/packages/bridge/src/notifications/dispatcher.ts b/packages/bridge/src/notifications/dispatcher.ts new file mode 100644 index 0000000..0222267 --- /dev/null +++ b/packages/bridge/src/notifications/dispatcher.ts @@ -0,0 +1,202 @@ +import { v4 as uuidv4 } from "uuid"; +import { eventBus } from "./event_bus"; +import type { EventQueue } from "../hooks/event_queue"; +import type { ConnectionManager } from "../websocket/connection_manager"; +import type { + ApprovalRequiredPayload, + BridgeMessage, + StreamChunkPayload, + StreamEndPayload, + StreamStartPayload, + ToolResultPayload, +} from "../types"; + +function timestamp(): string { + return new Date().toISOString(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export class Dispatcher { + private connectionManager: ConnectionManager; + private eventQueue?: EventQueue; + + constructor(connectionManager: ConnectionManager, eventQueue?: EventQueue) { + this.connectionManager = connectionManager; + this.eventQueue = eventQueue; + this.subscribe(); + } + + private subscribe(): void { + eventBus.onTyped("session-event", (payload) => { + if (!isRecord(payload) || typeof payload.type !== "string") { + return; + } + + switch (payload.type) { + case "stream_start": + this.broadcastToSession(payload.session_id, { + type: "stream_start", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: asString(payload.session_id) ?? "", + message_id: asString(payload.message_id) ?? "", + } satisfies StreamStartPayload, + }); + break; + + case "stream_end": + this.broadcastToSession(payload.session_id, { + type: "stream_end", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: asString(payload.session_id) ?? "", + message_id: asString(payload.message_id) ?? "", + finish_reason: asString(payload.finish_reason) ?? "stop", + } satisfies StreamEndPayload, + }); + break; + + case "session_closed": + this.broadcastToSession(payload.session_id, { + type: "session_end", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: asString(payload.session_id) ?? "", + reason: "completed", + }, + }); + break; + + default: + break; + } + }); + + eventBus.onTyped("tool-event", (payload) => { + if (!isRecord(payload) || typeof payload.type !== "string") { + return; + } + + if (payload.type === "approval_required") { + this.broadcastToSession(payload.session_id, { + type: "approval_required", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: asString(payload.session_id) ?? "", + tool_call_id: asString(payload.tool_call_id) ?? "", + tool: asString(payload.tool) ?? "unknown_tool", + params: isRecord(payload.params) ? payload.params : {}, + description: asString(payload.description) ?? "Approval required", + risk_level: + payload.risk_level === "low" || + payload.risk_level === "medium" || + payload.risk_level === "high" || + payload.risk_level === "critical" + ? payload.risk_level + : "medium", + source: payload.source === "hooks" ? "hooks" : "agent_sdk", + } satisfies ApprovalRequiredPayload, + }); + return; + } + + if (payload.type === "tool_result") { + const message: BridgeMessage = { + type: "tool_result", + id: uuidv4(), + timestamp: timestamp(), + payload: { + session_id: asString(payload.session_id) ?? "", + tool_call_id: asString(payload.tool_call_id) ?? "", + tool: asString(payload.tool) ?? "unknown_tool", + result: isRecord(payload.result) + ? { + success: payload.result.success === true, + content: asString(payload.result.content) ?? "", + diff: asString(payload.result.diff), + error: asString(payload.result.error), + duration_ms: + typeof payload.result.duration_ms === "number" + ? payload.result.duration_ms + : undefined, + } + : { + success: false, + content: "", + error: "Missing tool result payload", + }, + } satisfies ToolResultPayload, + }; + + this.broadcastToSession(payload.session_id, message, { queueForReplay: true }); + } + }); + + eventBus.onTyped("stream-chunk", (payload) => { + if (!isRecord(payload)) { + return; + } + + const chunkPayload: StreamChunkPayload = { + session_id: asString(payload.session_id) ?? "", + message_id: asString(payload.message_id) ?? "", + content: asString(payload.content) ?? "", + is_tool_use: false, + }; + + const msg: BridgeMessage = { + type: "stream_chunk", + id: uuidv4(), + timestamp: timestamp(), + payload: chunkPayload, + }; + + this.broadcastToSession(chunkPayload.session_id, msg); + }); + } + + dispatch(clientId: string, message: BridgeMessage): void { + this.connectionManager.sendToClient(clientId, message); + } + + broadcast(message: BridgeMessage, sessionId?: string): void { + if (sessionId) { + this.broadcastToSession(sessionId, message); + return; + } + + this.connectionManager.broadcast(message); + } + + private broadcastToSession( + sessionId: unknown, + message: BridgeMessage, + options: { + queueForReplay?: boolean; + } = {}, + ): void { + if (typeof sessionId !== "string" || sessionId.length === 0) { + return; + } + + if (options.queueForReplay) { + this.eventQueue?.enqueue(message, { sessionId }); + } + + const clients = this.connectionManager.getClientsForSession(sessionId); + for (const client of clients) { + this.connectionManager.sendToClient(client.id, message); + } + } +} diff --git a/packages/bridge/src/notifications/event_bus.ts b/packages/bridge/src/notifications/event_bus.ts new file mode 100644 index 0000000..7bd6d18 --- /dev/null +++ b/packages/bridge/src/notifications/event_bus.ts @@ -0,0 +1,30 @@ +import { EventEmitter } from "events"; + +export type EventBusEvents = { + "claude-event": [payload: unknown]; + "session-event": [payload: unknown]; + "tool-event": [payload: unknown]; + "stream-chunk": [payload: unknown]; +}; + +class TypedEventBus extends EventEmitter { + emitTyped(event: K, ...args: EventBusEvents[K]): boolean { + return this.emit(event, ...args); + } + + onTyped( + event: K, + listener: (...args: EventBusEvents[K]) => void, + ): this { + return this.on(event, listener as (...args: unknown[]) => void); + } + + offTyped( + event: K, + listener: (...args: EventBusEvents[K]) => void, + ): this { + return this.off(event, listener as (...args: unknown[]) => void); + } +} + +export const eventBus = new TypedEventBus(); diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts new file mode 100644 index 0000000..47dc9a0 --- /dev/null +++ b/packages/bridge/src/server.ts @@ -0,0 +1,86 @@ +import http from "http"; +import express from "express"; +import cors from "cors"; +import { config } from "./config"; +import { ConnectionManager } from "./websocket/connection_manager"; +import { MessageHandler } from "./websocket/message_handler"; +import { WebSocketServer } from "./websocket/server"; +import { AgentSessionManager } from "./agents/session_manager"; +import { AgentSdkAdapter } from "./agents/agent_sdk_adapter"; +import { GitService } from "./git/git_service"; +import { EventQueue } from "./hooks/event_queue"; +import { createHooksRouter } from "./hooks/receiver"; +import { Dispatcher } from "./notifications/dispatcher"; +import { rateLimiter } from "./auth/rate_limiter"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [Server] ${msg}`); +} + +export async function startServer(): Promise { + // Compose dependencies + const connectionManager = new ConnectionManager(); + const agentSessionManager = new AgentSessionManager(); + const agentSdkAdapter = new AgentSdkAdapter(agentSessionManager, connectionManager); + const gitService = new GitService(config.ALLOWED_PROJECT_ROOT); + const eventQueue = new EventQueue(); + + // Dispatcher subscribes to event bus, queues replayable events, and forwards to clients + const _dispatcher = new Dispatcher(connectionManager, eventQueue); + + const messageHandler = new MessageHandler( + connectionManager, + agentSdkAdapter, + agentSessionManager, + gitService, + eventQueue, + ); + + // Express app + const app = express(); + app.use(cors()); + app.use(express.json()); + app.use(rateLimiter); + + // Health check + app.get("/health", (_req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); + }); + + // Hook receiver + const hooksRouter = createHooksRouter(eventQueue, connectionManager); + app.use("/hooks", hooksRouter); + + // HTTP server + const httpServer = http.createServer(app); + + // WebSocket server + const _wsServer = new WebSocketServer(httpServer, connectionManager, messageHandler); + + // Start listening + await new Promise((resolve) => { + httpServer.listen(config.PORT, () => { + log(`Bridge server listening on port ${config.PORT}`); + log(`Allowed project root: ${config.ALLOWED_PROJECT_ROOT}`); + resolve(); + }); + }); + + // Graceful shutdown + const shutdown = (signal: string) => { + log(`Received ${signal}, shutting down...`); + httpServer.close(() => { + log("HTTP server closed"); + process.exit(0); + }); + + // Force exit after 10s + setTimeout(() => { + log("Forced shutdown after timeout"); + process.exit(1); + }, 10_000).unref(); + }; + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} diff --git a/packages/bridge/src/terminal/output_stream.ts b/packages/bridge/src/terminal/output_stream.ts new file mode 100644 index 0000000..b9beabd --- /dev/null +++ b/packages/bridge/src/terminal/output_stream.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from "events"; +import type { Readable } from "stream"; + +export class TerminalOutputStream extends EventEmitter { + private buffer: string[] = []; + private closed = false; + + constructor(readable?: Readable) { + super(); + if (readable) { + readable.on("data", (chunk: Buffer | string) => { + this.write(chunk.toString()); + }); + readable.on("end", () => { + this.close(); + }); + } + } + + write(data: string): void { + if (this.closed) return; + this.buffer.push(data); + this.emit("data", data); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.emit("close"); + } + + getBuffer(): string { + return this.buffer.join(""); + } + + isClosed(): boolean { + return this.closed; + } +} diff --git a/packages/bridge/src/terminal/terminal_manager.ts b/packages/bridge/src/terminal/terminal_manager.ts new file mode 100644 index 0000000..375e35a --- /dev/null +++ b/packages/bridge/src/terminal/terminal_manager.ts @@ -0,0 +1,102 @@ +import { spawn, type ChildProcess } from "child_process"; +import os from "os"; +import { v4 as uuidv4 } from "uuid"; +import { TerminalOutputStream } from "./output_stream"; +import { eventBus } from "../notifications/event_bus"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [TerminalManager] ${msg}`); +} + +export interface TerminalSession { + id: string; + workingDirectory: string; + process: ChildProcess; + outputStream: TerminalOutputStream; + createdAt: string; +} + +export class TerminalManager { + private sessions = new Map(); + + createSession(id: string, workingDirectory: string): TerminalSession { + const shell = os.platform() === "win32" ? "cmd.exe" : "/bin/sh"; + const args = os.platform() === "win32" ? [] : []; + + const proc = spawn(shell, args, { + cwd: workingDirectory, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + const outputStream = new TerminalOutputStream(); + + proc.stdout?.on("data", (chunk: Buffer) => { + const data = chunk.toString(); + outputStream.write(data); + this.forwardOutput(id, data); + }); + + proc.stderr?.on("data", (chunk: Buffer) => { + const data = chunk.toString(); + outputStream.write(data); + this.forwardOutput(id, data); + }); + + proc.on("close", (code) => { + log(`Session ${id} exited with code ${code}`); + outputStream.close(); + eventBus.emitTyped("session-event", { + type: "terminal_exit", + session_id: id, + exit_code: code, + }); + }); + + const session: TerminalSession = { + id, + workingDirectory, + process: proc, + outputStream, + createdAt: new Date().toISOString(), + }; + + this.sessions.set(id, session); + log(`Created terminal session: ${id} (cwd=${workingDirectory})`); + return session; + } + + getSession(id: string): TerminalSession | undefined { + return this.sessions.get(id); + } + + closeSession(id: string): void { + const session = this.sessions.get(id); + if (!session) return; + try { + session.process.kill(); + } catch (_) { + // already dead + } + session.outputStream.close(); + this.sessions.delete(id); + log(`Closed terminal session: ${id}`); + } + + sendInput(id: string, data: string): void { + const session = this.sessions.get(id); + if (!session) { + log(`sendInput: session not found: ${id}`); + return; + } + session.process.stdin?.write(data); + } + + private forwardOutput(sessionId: string, data: string): void { + eventBus.emitTyped("tool-event", { + type: "terminal_output", + session_id: sessionId, + data, + }); + } +} diff --git a/packages/bridge/src/types.ts b/packages/bridge/src/types.ts new file mode 100644 index 0000000..ea98cac --- /dev/null +++ b/packages/bridge/src/types.ts @@ -0,0 +1,314 @@ +import type WebSocket from "ws"; + +export const SUPPORTED_AGENTS = ["claude-code"] as const; + +export type SupportedAgent = (typeof SUPPORTED_AGENTS)[number]; +export type AgentSessionStatus = "active" | "idle" | "closed"; +export type RiskLevel = "low" | "medium" | "high" | "critical"; +export type ApprovalDecision = "approved" | "rejected" | "modified"; + +export interface BridgeMessage { + type: string; + id?: string; + timestamp: string; + payload: T; +} + +export interface AuthPayload { + token: string; + client_version?: string; + platform?: string; +} + +export interface ActiveSessionPayload { + session_id: string; + agent: SupportedAgent; + title: string; + working_directory: string; + status: AgentSessionStatus; +} + +export interface ConnectionAckPayload { + server_version: string; + supported_agents: SupportedAgent[]; + active_sessions: ActiveSessionPayload[]; +} + +export interface ConnectionErrorPayload { + code: string; + message: string; +} + +export type HeartbeatPingPayload = Record; +export type HeartbeatPongPayload = Record; + +export interface SessionStartPayload { + agent?: string; + session_id?: string | null; + working_directory?: string; + resume?: boolean; + system_prompt?: string; + model?: string; +} + +export interface SessionReadyPayload { + session_id: string; + agent: SupportedAgent; + working_directory: string; + status: "ready"; + model: string; + branch?: string; +} + +export interface SessionEndPayload { + session_id: string; + reason?: "user_request" | "timeout" | "error" | "completed"; +} + +export interface MessagePayload { + session_id: string; + content: string; + role?: "user" | "assistant"; +} + +export interface StreamStartPayload { + session_id: string; + message_id: string; +} + +export interface StreamChunkPayload { + session_id: string; + message_id: string; + content: string; + is_tool_use: boolean; +} + +export interface StreamEndPayload { + session_id: string; + message_id: string; + finish_reason: string; +} + +export interface ToolCallPayload { + session_id: string; + tool_call_id: string; + tool: string; + params: Record; + description: string; +} + +export interface ApprovalRequiredPayload { + session_id: string; + tool_call_id: string; + tool: string; + params: Record; + description: string; + risk_level: RiskLevel; + source: "hooks" | "agent_sdk"; +} + +export interface ApprovalResponsePayload { + session_id: string; + tool_call_id: string; + decision: ApprovalDecision; + modifications: Record | null; +} + +export interface ToolExecutionResult { + success: boolean; + content: string; + diff?: string; + error?: string; + duration_ms?: number; +} + +export interface ToolResultPayload { + session_id: string; + tool_call_id: string; + tool: string; + result: ToolExecutionResult; +} + +export interface ClaudeEventPayload { + event_type: string; + session_id: string; + timestamp: string; + payload: Record; +} + +export interface GitFileChange { + path: string; + status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unknown"; + staged: boolean; +} + +export interface DiffLine { + type: "context" | "added" | "removed"; + content: string; + old_line_number?: number; + new_line_number?: number; +} + +export interface DiffHunk { + old_start: number; + old_lines: number; + new_start: number; + new_lines: number; + header: string; + lines: DiffLine[]; +} + +export interface DiffFile { + path: string; + old_path: string; + new_path: string; + status: "added" | "modified" | "deleted" | "renamed"; + additions: number; + deletions: number; + hunks: DiffHunk[]; +} + +export interface GitStatusRequestPayload { + session_id?: string; +} + +export interface GitStatusPayload { + session_id?: string; + branch: string; + ahead: number; + behind: number; + is_clean: boolean; + changes: GitFileChange[]; +} + +export interface GitStatusResponsePayload extends GitStatusPayload {} + +export interface GitDiffPayload { + session_id?: string; + files?: string[]; + cached?: boolean; +} + +export interface GitDiffResponsePayload { + session_id?: string; + files: DiffFile[]; +} + +export interface GitCommitPayload { + session_id?: string; + message: string; + files?: string[]; +} + +export interface GitBranch { + name: string; + is_current: boolean; + is_remote: boolean; +} + +export interface FileListPayload { + session_id?: string; + path: string; + offset?: number; + limit?: number; + includeHidden?: boolean; +} + +export interface FileEntry { + name: string; + path?: string; + type: "file" | "directory"; + size?: number; + modified?: string; +} + +export interface FileListResponsePayload { + session_id?: string; + path: string; + entries: FileEntry[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; +} + +export interface FileReadPayload { + session_id?: string; + path: string; + offset?: number; + limit?: number; +} + +export interface FileReadResponsePayload { + session_id?: string; + path: string; + content: string; + size: number; + lines: number; + offset?: number; + limit?: number; + hasMore?: boolean; + encoding?: "utf8"; +} + +export interface NotificationPayload { + notification_id?: string; + session_id?: string; + notification_type: string; + title: string; + body: string; + priority: "low" | "normal" | "high"; + data?: Record; +} + +export interface NotificationAckPayload { + notification_ids: string[]; +} + +export interface ErrorPayload { + code: string; + message: string; + session_id?: string; + recoverable?: boolean; + request_type?: string; +} + +export interface MobileClient { + id: string; + ws: WebSocket; + sessionIds: string[]; + authenticated: boolean; +} + +export interface AgentSession { + id: string; + agent: SupportedAgent; + title: string; + model: string; + working_directory: string; + created_at: string; + status: AgentSessionStatus; +} + +export interface SessionConfig { + agent?: SupportedAgent; + sessionId?: string; + workingDirectory?: string; + systemPrompt?: string; + model?: string; +} + +export interface HookEvent { + event_type: string; + session_id: string; + timestamp: string; + payload: Record; +} + +export interface ToolResult { + success: boolean; + content: string; + diff?: string; + error?: string; + durationMs: number; +} diff --git a/packages/bridge/src/websocket/connection_manager.ts b/packages/bridge/src/websocket/connection_manager.ts new file mode 100644 index 0000000..94f0b84 --- /dev/null +++ b/packages/bridge/src/websocket/connection_manager.ts @@ -0,0 +1,80 @@ +import type WebSocket from "ws"; +import type { MobileClient, BridgeMessage } from "../types"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [ConnectionManager] ${msg}`); +} + +export class ConnectionManager { + private clients = new Map(); + + addClient(id: string, ws: WebSocket): void { + const client: MobileClient = { + id, + ws, + sessionIds: [], + authenticated: false, + }; + this.clients.set(id, client); + log(`Client added: ${id}`); + } + + authenticateClient(id: string): void { + const client = this.clients.get(id); + if (client) { + client.authenticated = true; + log(`Client authenticated: ${id}`); + } + } + + removeClient(id: string): void { + this.clients.delete(id); + log(`Client removed: ${id}`); + } + + getClient(id: string): MobileClient | undefined { + return this.clients.get(id); + } + + broadcast(message: BridgeMessage, filter?: (client: MobileClient) => boolean): void { + const json = JSON.stringify(message); + for (const client of this.clients.values()) { + if (!client.authenticated) continue; + if (filter && !filter(client)) continue; + this.sendRaw(client, json); + } + } + + sendToClient(id: string, message: BridgeMessage): void { + const client = this.clients.get(id); + if (!client) return; + this.sendRaw(client, JSON.stringify(message)); + } + + addSessionToClient(clientId: string, sessionId: string): void { + const client = this.clients.get(clientId); + if (client && !client.sessionIds.includes(sessionId)) { + client.sessionIds.push(sessionId); + } + } + + getClientsForSession(sessionId: string): MobileClient[] { + const result: MobileClient[] = []; + for (const client of this.clients.values()) { + if (client.authenticated && client.sessionIds.includes(sessionId)) { + result.push(client); + } + } + return result; + } + + private sendRaw(client: MobileClient, json: string): void { + try { + if (client.ws.readyState === 1 /* OPEN */) { + client.ws.send(json); + } + } catch (err) { + log(`Failed to send to client ${client.id}: ${String(err)}`); + } + } +} diff --git a/packages/bridge/src/websocket/message_handler.ts b/packages/bridge/src/websocket/message_handler.ts new file mode 100644 index 0000000..80e3281 --- /dev/null +++ b/packages/bridge/src/websocket/message_handler.ts @@ -0,0 +1,392 @@ +import path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { config } from "../config"; +import type { ConnectionManager } from "./connection_manager"; +import type { AgentSdkAdapter } from "../agents/agent_sdk_adapter"; +import type { GitService } from "../git/git_service"; +import { SUPPORTED_AGENTS } from "../types"; +import type { + ActiveSessionPayload, + ApprovalResponsePayload, + AuthPayload, + BridgeMessage, + ConnectionAckPayload, + ConnectionErrorPayload, + ErrorPayload, + FileListPayload, + FileListResponsePayload, + FileReadPayload, + FileReadResponsePayload, + GitCommitPayload, + GitDiffPayload, + GitDiffResponsePayload, + GitStatusPayload, + GitStatusRequestPayload, + HeartbeatPongPayload, + MessagePayload, + NotificationAckPayload, + SessionEndPayload, + SessionStartPayload, +} from "../types"; +import type { AgentSessionManager } from "../agents/session_manager"; +import { FileService } from "../files/file_service"; +import type { EventQueue } from "../hooks/event_queue"; + +const SERVER_VERSION = "0.1.0"; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [MessageHandler] ${msg}`); +} + +function ts(): string { + return new Date().toISOString(); +} + +function errorMsg( + code: string, + message: string, + requestType?: string, + sessionId?: string, +): BridgeMessage { + return { + type: "error", + id: uuidv4(), + timestamp: ts(), + payload: { + code, + message, + request_type: requestType, + session_id: sessionId, + recoverable: code !== "AUTH_FAILED", + }, + }; +} + +function mapActiveSession(session: { + id: string; + agent: "claude-code"; + title: string; + working_directory: string; + status: "active" | "idle" | "closed"; +}): ActiveSessionPayload { + return { + session_id: session.id, + agent: session.agent, + title: session.title, + working_directory: session.working_directory, + status: session.status, + }; +} + +export class MessageHandler { + private fileService: FileService; + + constructor( + private connectionManager: ConnectionManager, + private agentSdkAdapter: AgentSdkAdapter, + private agentSessionManager: AgentSessionManager, + private gitService: GitService, + private eventQueue: EventQueue, + ) { + this.fileService = new FileService(config.ALLOWED_PROJECT_ROOT); + } + + async handle(clientId: string, rawMessage: string): Promise { + let msg: BridgeMessage; + + try { + msg = JSON.parse(rawMessage) as BridgeMessage; + } catch { + this.connectionManager.sendToClient( + clientId, + errorMsg("BRIDGE_ERROR", "Invalid JSON message"), + ); + return; + } + + const { type, payload, id } = msg; + + if (type === "auth") { + await this.handleAuth(clientId, payload as AuthPayload, id); + return; + } + + if (type === "heartbeat_ping") { + this.handleHeartbeat(clientId, id); + return; + } + + const client = this.connectionManager.getClient(clientId); + if (!client || !client.authenticated) { + this.connectionManager.sendToClient(clientId, { + type: "connection_error", + id: id ?? uuidv4(), + timestamp: ts(), + payload: { + code: "AUTH_FAILED", + message: "Client must authenticate before sending messages", + } as ConnectionErrorPayload, + }); + return; + } + + try { + switch (type) { + case "session_start": + await this.agentSdkAdapter.handleSessionStart( + payload as SessionStartPayload, + clientId, + id, + ); + break; + + case "message": + await this.agentSdkAdapter.handleMessage(payload as MessagePayload, clientId); + break; + + case "approval_response": + await this.agentSdkAdapter.handleApprovalResponse( + payload as ApprovalResponsePayload, + clientId, + ); + break; + + case "session_end": + this.agentSdkAdapter.handleSessionEnd(payload as SessionEndPayload); + break; + + case "git_status_request": + await this.handleGitStatusRequest( + clientId, + (payload as GitStatusRequestPayload | undefined)?.session_id, + id, + ); + break; + + case "git_commit": + await this.handleGitCommit(clientId, payload as GitCommitPayload, id); + break; + + case "git_diff": + await this.handleGitDiff(clientId, payload as GitDiffPayload, id); + break; + + case "file_list": + await this.handleFileList(clientId, payload as FileListPayload, id); + break; + + case "file_read": + await this.handleFileRead(clientId, payload as FileReadPayload, id); + break; + + case "notification_ack": + this.handleNotificationAck(payload as NotificationAckPayload); + break; + + default: + log(`Unknown message type: ${type} from client ${clientId}`); + this.connectionManager.sendToClient( + clientId, + errorMsg("BRIDGE_ERROR", `Unknown message type: ${type}`, type), + ); + } + } catch (err) { + const sessionId = + typeof payload === "object" && payload !== null && "session_id" in payload + ? String((payload as { session_id?: unknown }).session_id ?? "") + : undefined; + log(`Error handling ${type} for client ${clientId}: ${String(err)}`); + this.connectionManager.sendToClient( + clientId, + errorMsg("BRIDGE_ERROR", String(err), type, sessionId), + ); + } + } + + private async handleAuth( + clientId: string, + payload: AuthPayload, + requestId?: string, + ): Promise { + if (!payload?.token || payload.token !== config.BRIDGE_TOKEN) { + this.connectionManager.sendToClient(clientId, { + type: "connection_error", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: { + code: "AUTH_FAILED", + message: "Invalid or expired token", + } as ConnectionErrorPayload, + }); + log(`Auth failed for client ${clientId}`); + return; + } + + this.connectionManager.authenticateClient(clientId); + + const activeSessions = this.agentSessionManager.getActiveSessions().map(mapActiveSession); + + const ackMsg: BridgeMessage = { + type: "connection_ack", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: { + server_version: SERVER_VERSION, + supported_agents: [...SUPPORTED_AGENTS], + active_sessions: activeSessions, + }, + }; + this.connectionManager.sendToClient(clientId, ackMsg); + + for (const queuedMessage of this.eventQueue.replay()) { + this.connectionManager.sendToClient(clientId, queuedMessage); + } + + log(`Auth succeeded for client ${clientId}`); + } + + private handleHeartbeat(clientId: string, requestId?: string): void { + const pong: BridgeMessage = { + type: "heartbeat_pong", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: {}, + }; + this.connectionManager.sendToClient(clientId, pong); + } + + private handleNotificationAck(payload: NotificationAckPayload): void { + if (!Array.isArray(payload.notification_ids)) { + return; + } + + this.eventQueue.acknowledgeNotifications( + payload.notification_ids.filter((value): value is string => typeof value === "string"), + ); + } + + private async handleGitStatusRequest( + clientId: string, + sessionId: string | undefined, + requestId?: string, + ): Promise { + const status = await this.gitService.getStatus(); + const responsePayload: GitStatusPayload = { + ...status, + session_id: sessionId, + }; + + this.connectionManager.sendToClient(clientId, { + type: "git_status_response", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: responsePayload, + }); + } + + private async handleGitCommit( + clientId: string, + payload: GitCommitPayload, + requestId?: string, + ): Promise { + await this.gitService.commit(payload.message, payload.files); + + this.connectionManager.sendToClient(clientId, { + type: "git_status_response", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: { + ...(await this.gitService.getStatus()), + session_id: payload.session_id, + } satisfies GitStatusPayload, + }); + } + + private async handleGitDiff( + clientId: string, + payload: GitDiffPayload, + requestId?: string, + ): Promise { + const files = await this.gitService.getDiff(payload.files, payload.cached); + const responsePayload: GitDiffResponsePayload = { + session_id: payload.session_id, + files, + }; + + this.connectionManager.sendToClient(clientId, { + type: "git_diff_response", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: responsePayload, + }); + } + + private async handleFileList( + clientId: string, + payload: FileListPayload, + requestId?: string, + ): Promise { + const dirPath = path.resolve(payload.path); + const result = await this.fileService.listDirectory(dirPath, { + offset: payload.offset, + limit: payload.limit, + includeHidden: payload.includeHidden, + }); + + const entries = result.entries.map((entry) => ({ + name: entry.name, + path: path.join(dirPath, entry.name), + type: entry.type, + size: entry.size, + modified: entry.modifiedAt, + })); + + const responsePayload: FileListResponsePayload = { + session_id: payload.session_id, + path: dirPath, + entries, + total: result.total, + offset: result.offset, + limit: result.limit, + hasMore: result.hasMore, + }; + + this.connectionManager.sendToClient(clientId, { + type: "file_list_response", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: responsePayload, + }); + } + + private async handleFileRead( + clientId: string, + payload: FileReadPayload, + requestId?: string, + ): Promise { + const filePath = path.resolve(payload.path); + const result = await this.fileService.readFile(filePath, { + offset: payload.offset, + limit: payload.limit, + }); + + const responsePayload: FileReadResponsePayload = { + session_id: payload.session_id, + path: filePath, + content: result.content, + size: Buffer.byteLength(result.content, "utf8"), + lines: result.totalLines, + offset: result.offset, + limit: result.limit, + hasMore: result.hasMore, + encoding: "utf8", + }; + + this.connectionManager.sendToClient(clientId, { + type: "file_read_response", + id: requestId ?? uuidv4(), + timestamp: ts(), + payload: responsePayload, + }); + } +} diff --git a/packages/bridge/src/websocket/server.ts b/packages/bridge/src/websocket/server.ts new file mode 100644 index 0000000..c9ed12e --- /dev/null +++ b/packages/bridge/src/websocket/server.ts @@ -0,0 +1,86 @@ +import { WebSocketServer as WsServer, type WebSocket } from "ws"; +import { v4 as uuidv4 } from "uuid"; +import type { IncomingMessage, Server } from "http"; +import type { ConnectionManager } from "./connection_manager"; +import type { MessageHandler } from "./message_handler"; + +const PING_INTERVAL_MS = 30_000; + +function log(msg: string): void { + console.log(`[${new Date().toISOString()}] [WebSocketServer] ${msg}`); +} + +export class WebSocketServer { + private wss: WsServer; + private pingInterval: NodeJS.Timeout | null = null; + + constructor( + httpServer: Server, + private connectionManager: ConnectionManager, + private messageHandler: MessageHandler, + ) { + this.wss = new WsServer({ + server: httpServer, + perMessageDeflate: { + zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 }, + zlibInflateOptions: { chunkSize: 10 * 1024 }, + clientNoContextTakeover: true, + serverNoContextTakeover: true, + serverMaxWindowBits: 10, + concurrencyLimit: 10, + threshold: 1024, // only compress messages > 1KB + }, + }); + this.setup(); + } + + private setup(): void { + this.wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { + const clientId = uuidv4(); + log(`New connection: ${clientId} from ${req.socket.remoteAddress}`); + + this.connectionManager.addClient(clientId, ws); + + ws.on("message", (data) => { + const raw = data.toString(); + this.messageHandler.handle(clientId, raw).catch((err) => { + log(`Unhandled error from client ${clientId}: ${String(err)}`); + }); + }); + + ws.on("close", (code, reason) => { + log(`Client disconnected: ${clientId} (code=${code}, reason=${reason.toString()})`); + this.connectionManager.removeClient(clientId); + }); + + ws.on("error", (err) => { + log(`WebSocket error for client ${clientId}: ${String(err)}`); + this.connectionManager.removeClient(clientId); + }); + }); + + this.wss.on("error", (err) => { + log(`WebSocketServer error: ${String(err)}`); + }); + + this.startPingInterval(); + } + + private startPingInterval(): void { + this.pingInterval = setInterval(() => { + this.wss.clients.forEach((ws) => { + if (ws.readyState === ws.OPEN) { + ws.ping(); + } + }); + }, PING_INTERVAL_MS); + } + + close(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + this.wss.close(); + } +} diff --git a/packages/bridge/tests/agents/agent_sdk_adapter.test.ts b/packages/bridge/tests/agents/agent_sdk_adapter.test.ts new file mode 100644 index 0000000..f414673 --- /dev/null +++ b/packages/bridge/tests/agents/agent_sdk_adapter.test.ts @@ -0,0 +1,223 @@ +import { AgentSdkAdapter } from "../../src/agents/agent_sdk_adapter"; +import type { ConnectionManager } from "../../src/websocket/connection_manager"; +import type { BridgeMessage } from "../../src/types"; + +describe("AgentSdkAdapter", () => { + describe("handleSessionStart - resume behavior", () => { + function createMockDependencies() { + const sentMessages: Array<{ clientId: string; message: BridgeMessage }> = []; + + const connectionManager = { + sendToClient: jest.fn((clientId: string, message: BridgeMessage) => { + sentMessages.push({ clientId, message }); + }), + addSessionToClient: jest.fn(), + }; + + const sessionManager = { + createSession: jest.fn, [unknown]>(), + resumeSession: jest.fn, [string]>(), + getSession: jest.fn(), + closeSession: jest.fn(), + }; + + return { + connectionManager: connectionManager as unknown as ConnectionManager, + sessionManager, + sentMessages, + }; + } + + it("creates a new session when resume is false", async () => { + const { connectionManager, sessionManager, sentMessages } = createMockDependencies(); + + sessionManager.createSession.mockResolvedValue("new-session-id"); + sessionManager.getSession.mockReturnValue({ + id: "new-session-id", + agent: "claude-code", + title: "project", + model: "claude-opus-4-6", + working_directory: "/repo/project", + created_at: new Date().toISOString(), + status: "idle", + }); + + const adapter = new AgentSdkAdapter(sessionManager as never, connectionManager as never); + + await adapter.handleSessionStart( + { + agent: "claude-code", + working_directory: "/repo/project", + resume: false, + }, + "client-1", + "req-123", + ); + + expect(sessionManager.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "claude-code", + workingDirectory: "/repo/project", + }), + ); + expect(sessionManager.resumeSession).not.toHaveBeenCalled(); + expect(connectionManager.addSessionToClient).toHaveBeenCalledWith( + "client-1", + "new-session-id", + ); + + expect(sentMessages[0]?.message).toMatchObject({ + type: "session_ready", + id: "req-123", + payload: { + session_id: "new-session-id", + agent: "claude-code", + working_directory: "/repo/project", + status: "ready", + }, + }); + }); + + it("creates a new session when resume is true but session_id is missing", async () => { + const { connectionManager, sessionManager, sentMessages } = createMockDependencies(); + + sessionManager.createSession.mockResolvedValue("new-session-id"); + sessionManager.getSession.mockReturnValue({ + id: "new-session-id", + agent: "claude-code", + title: "project", + model: "claude-opus-4-6", + working_directory: "/repo/project", + created_at: new Date().toISOString(), + status: "idle", + }); + + const adapter = new AgentSdkAdapter(sessionManager as never, connectionManager as never); + + // resume: true but no session_id provided + await adapter.handleSessionStart( + { + agent: "claude-code", + working_directory: "/repo/project", + resume: true, + // session_id is undefined/null + }, + "client-1", + "req-123", + ); + + // Should create a new session since session_id is not a valid string + expect(sessionManager.createSession).toHaveBeenCalled(); + expect(sessionManager.resumeSession).not.toHaveBeenCalled(); + }); + + it("resumes existing session when resume is true and session_id is provided", async () => { + const { connectionManager, sessionManager, sentMessages } = createMockDependencies(); + + sessionManager.resumeSession.mockResolvedValue(undefined); + sessionManager.getSession.mockReturnValue({ + id: "existing-session-id", + agent: "claude-code", + title: "resumed-project", + model: "claude-opus-4-6", + working_directory: "/repo/resumed", + created_at: new Date().toISOString(), + status: "idle", + }); + + const adapter = new AgentSdkAdapter(sessionManager as never, connectionManager as never); + + await adapter.handleSessionStart( + { + agent: "claude-code", + session_id: "existing-session-id", + working_directory: "/repo/resumed", + resume: true, + }, + "client-1", + "req-resume-1", + ); + + expect(sessionManager.resumeSession).toHaveBeenCalledWith("existing-session-id"); + expect(sessionManager.createSession).not.toHaveBeenCalled(); + expect(connectionManager.addSessionToClient).toHaveBeenCalledWith( + "client-1", + "existing-session-id", + ); + + expect(sentMessages[0]?.message).toMatchObject({ + type: "session_ready", + id: "req-resume-1", + payload: { + session_id: "existing-session-id", + agent: "claude-code", + working_directory: "/repo/resumed", + status: "ready", + }, + }); + }); + + it("sends error when resume fails for non-existent session", async () => { + const { connectionManager, sessionManager, sentMessages } = createMockDependencies(); + + sessionManager.resumeSession.mockRejectedValue( + new Error("Session not found: nonexistent-id"), + ); + + const adapter = new AgentSdkAdapter(sessionManager as never, connectionManager as never); + + await adapter.handleSessionStart( + { + agent: "claude-code", + session_id: "nonexistent-id", + resume: true, + }, + "client-1", + "req-resume-fail", + ); + + expect(sessionManager.resumeSession).toHaveBeenCalledWith("nonexistent-id"); + expect(connectionManager.addSessionToClient).not.toHaveBeenCalled(); + + expect(sentMessages[0]?.message).toMatchObject({ + type: "error", + id: "req-resume-fail", + payload: { + code: "BRIDGE_ERROR", + message: expect.stringContaining("Session not found"), + request_type: "session_start", + recoverable: false, + }, + }); + }); + + it("sends error when session not found after resume", async () => { + const { connectionManager, sessionManager, sentMessages } = createMockDependencies(); + + // Resume succeeds but session isn't found after (edge case) + sessionManager.resumeSession.mockResolvedValue(undefined); + sessionManager.getSession.mockReturnValue(undefined); + + const adapter = new AgentSdkAdapter(sessionManager as never, connectionManager as never); + + await adapter.handleSessionStart( + { + agent: "claude-code", + session_id: "ghost-session-id", + resume: true, + }, + "client-1", + "req-ghost", + ); + + expect(sentMessages[0]?.message).toMatchObject({ + type: "error", + payload: { + code: "BRIDGE_ERROR", + message: expect.stringContaining("Session not found after start"), + request_type: "session_start", + }, + }); + }); + }); +}); diff --git a/packages/bridge/tests/agents/session_manager.test.ts b/packages/bridge/tests/agents/session_manager.test.ts new file mode 100644 index 0000000..dce5f1b --- /dev/null +++ b/packages/bridge/tests/agents/session_manager.test.ts @@ -0,0 +1,324 @@ +import { AgentSessionManager } from "../../src/agents/session_manager"; +import { eventBus } from "../../src/notifications/event_bus"; +import type { + AgentRuntime, + AgentRuntimeTurnRequest, + AgentRuntimeTurnResult, +} from "../../src/agents/agent_runtime"; + +function nextTick(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +describe("AgentSessionManager", () => { + afterEach(() => { + eventBus.removeAllListeners(); + }); + + // ========================================================================= + // Session Resume Tests + // ========================================================================= + + describe("resumeSession", () => { + it("throws when session does not exist", async () => { + const runtime: AgentRuntime = { runTurn: jest.fn() }; + const toolExecutor = { getToolDefinitions: jest.fn(() => []), execute: jest.fn() }; + const manager = new AgentSessionManager(runtime, toolExecutor as never); + + await expect(manager.resumeSession("nonexistent-session")).rejects.toThrow( + "Session not found: nonexistent-session", + ); + }); + + it("sets session status to idle and preserves metadata", async () => { + const runtime: AgentRuntime = { runTurn: jest.fn() }; + const toolExecutor = { getToolDefinitions: jest.fn(() => []), execute: jest.fn() }; + const manager = new AgentSessionManager(runtime, toolExecutor as never); + + const sessionId = await manager.createSession({ + workingDirectory: process.cwd(), + model: "claude-opus-4-6", + }); + + // Verify session was created + const sessionBefore = manager.getSession(sessionId); + expect(sessionBefore).toBeDefined(); + expect(sessionBefore!.status).toBe("idle"); + + // Close the session + manager.closeSession(sessionId); + + // Verify session is removed and can't be resumed + await expect(manager.resumeSession(sessionId)).rejects.toThrow( + `Session not found: ${sessionId}`, + ); + }); + + it("resuming an active session sets status to idle", async () => { + const runtime: AgentRuntime = { runTurn: jest.fn() }; + const toolExecutor = { getToolDefinitions: jest.fn(() => []), execute: jest.fn() }; + const manager = new AgentSessionManager(runtime, toolExecutor as never); + + const sessionId = await manager.createSession({ + workingDirectory: process.cwd(), + model: "claude-opus-4-6", + }); + + // Verify session exists and is idle + const sessionBefore = manager.getSession(sessionId); + expect(sessionBefore).toBeDefined(); + expect(sessionBefore!.status).toBe("idle"); + + // Resume should succeed and keep it idle + await manager.resumeSession(sessionId); + + const sessionAfter = manager.getSession(sessionId); + expect(sessionAfter).toBeDefined(); + expect(sessionAfter!.status).toBe("idle"); + }); + + it("preserves session history across resume", async () => { + const runTurn = jest + .fn, [AgentRuntimeTurnRequest]>() + .mockImplementationOnce(async () => ({ + stopReason: "end_turn", + message: { + role: "assistant", + content: [{ type: "text", text: "Response" }], + }, + })); + + const runtime: AgentRuntime = { runTurn }; + const toolExecutor = { getToolDefinitions: jest.fn(() => []), execute: jest.fn() }; + const manager = new AgentSessionManager(runtime, toolExecutor as never); + + const sessionId = await manager.createSession({ + workingDirectory: process.cwd(), + model: "claude-opus-4-6", + }); + + // Send a message to populate history + await manager.sendMessage(sessionId, "First message", "client-1"); + + // Verify session is still accessible + const session = manager.getSession(sessionId); + expect(session).toBeDefined(); + + // Resume should succeed + await expect(manager.resumeSession(sessionId)).resolves.toBeUndefined(); + }); + }); + + it("waits for approval, executes the tool, and resumes the session turn", async () => { + const runTurn = jest + .fn, [AgentRuntimeTurnRequest]>() + .mockImplementationOnce(async (request) => { + request.onTextDelta?.("Planning change..."); + return { + stopReason: "tool_use", + message: { + role: "assistant", + content: [ + { type: "text", text: "Planning change..." }, + { + type: "tool_use", + id: "tool-call-1", + name: "run_command", + input: { command: "node -e console.log(1)" }, + }, + ], + }, + }; + }) + .mockImplementationOnce(async (request) => { + expect(request.messages.at(-1)).toMatchObject({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-call-1", + is_error: false, + }, + ], + }); + + request.onTextDelta?.("Done."); + return { + stopReason: "end_turn", + message: { + role: "assistant", + content: [{ type: "text", text: "Done." }], + }, + }; + }); + + const runtime: AgentRuntime = { + runTurn, + }; + + const toolExecutor = { + getToolDefinitions: jest.fn(() => []), + execute: jest.fn(async () => ({ + success: true, + content: "command output", + durationMs: 17, + })), + }; + + const manager = new AgentSessionManager(runtime, toolExecutor as never); + const streamChunks: string[] = []; + const sessionEvents: Array> = []; + const toolEvents: Array> = []; + + eventBus.onTyped("stream-chunk", (payload) => { + streamChunks.push(String((payload as { content?: unknown }).content ?? "")); + }); + eventBus.onTyped("session-event", (payload) => { + sessionEvents.push(payload as Record); + }); + eventBus.onTyped("tool-event", (payload) => { + toolEvents.push(payload as Record); + }); + + const sessionId = await manager.createSession({ + workingDirectory: process.cwd(), + model: "claude-opus-4-6", + }); + + const sendPromise = manager.sendMessage(sessionId, "Run the command", "client-1"); + await nextTick(); + + expect(toolEvents).toContainEqual( + expect.objectContaining({ + type: "approval_required", + session_id: sessionId, + tool_call_id: "tool-call-1", + tool: "run_command", + }), + ); + + await manager.executeToolCall(sessionId, "tool-call-1", "modified", { + command: "node -e console.log(2)", + }); + await sendPromise; + + expect(toolExecutor.execute).toHaveBeenCalledWith( + "run_command", + { command: "node -e console.log(2)" }, + process.cwd(), + ); + expect(toolEvents).toContainEqual( + expect.objectContaining({ + type: "tool_result", + session_id: sessionId, + tool_call_id: "tool-call-1", + tool: "run_command", + result: expect.objectContaining({ + success: true, + content: "command output", + duration_ms: 17, + }), + }), + ); + expect(streamChunks).toEqual(["Planning change...", "Done."]); + expect(sessionEvents).toContainEqual( + expect.objectContaining({ + type: "stream_end", + session_id: sessionId, + finish_reason: "stop", + }), + ); + expect(runTurn).toHaveBeenCalledTimes(2); + }); + + it("turns rejected approvals into tool_result feedback without running the tool", async () => { + const runTurn = jest + .fn, [AgentRuntimeTurnRequest]>() + .mockImplementationOnce(async () => ({ + stopReason: "tool_use", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-call-2", + name: "edit_file", + input: { + path: "README.md", + old_string: "old", + new_string: "new", + }, + }, + ], + }, + })) + .mockImplementationOnce(async (request) => { + expect(request.messages.at(-1)).toMatchObject({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-call-2", + is_error: true, + content: expect.stringContaining("rejected by user"), + }, + ], + }); + + return { + stopReason: "end_turn", + message: { + role: "assistant", + content: [{ type: "text", text: "Okay, I will not apply it." }], + }, + }; + }); + + const runtime: AgentRuntime = { runTurn }; + const toolExecutor = { + getToolDefinitions: jest.fn(() => []), + execute: jest.fn(), + }; + + const manager = new AgentSessionManager(runtime, toolExecutor as never); + const toolEvents: Array> = []; + eventBus.onTyped("tool-event", (payload) => { + toolEvents.push(payload as Record); + }); + + const sessionId = await manager.createSession({ workingDirectory: process.cwd() }); + const sendPromise = manager.sendMessage(sessionId, "Try editing the file", "client-1"); + await nextTick(); + + await manager.executeToolCall(sessionId, "tool-call-2", "rejected", null); + await sendPromise; + + expect(toolExecutor.execute).not.toHaveBeenCalled(); + expect(toolEvents).toContainEqual( + expect.objectContaining({ + type: "tool_rejected", + session_id: sessionId, + tool_call_id: "tool-call-2", + }), + ); + }); + + it("rejects approval responses for unknown tool calls", async () => { + const runtime: AgentRuntime = { + runTurn: jest.fn(), + }; + + const manager = new AgentSessionManager(runtime, { + getToolDefinitions: jest.fn(() => []), + execute: jest.fn(), + } as never); + + const sessionId = await manager.createSession({ workingDirectory: process.cwd() }); + + await expect( + manager.executeToolCall(sessionId, "missing-tool-call", "approved", null), + ).rejects.toThrow("Tool call not found: missing-tool-call"); + }); +}); diff --git a/packages/bridge/tests/agents/tool_executor.test.ts b/packages/bridge/tests/agents/tool_executor.test.ts new file mode 100644 index 0000000..e804a7a --- /dev/null +++ b/packages/bridge/tests/agents/tool_executor.test.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import path from "path"; +import { ToolExecutor } from "../../src/agents/tool_executor"; + +describe("ToolExecutor", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(process.cwd(), "tool-executor-")); + fs.writeFileSync(path.join(tempDir, "sample.txt"), "hello world\n", "utf8"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("supports Agent SDK style aliases and protocol tool names", async () => { + const executor = new ToolExecutor(); + + const readResult = await executor.execute("Read", { path: "sample.txt" }, tempDir); + const lsResult = await executor.execute("ls", { path: "." }, tempDir); + const commandResult = await executor.execute( + "Bash", + { command: "denied_command --flag" }, + tempDir, + ); + + expect(readResult).toMatchObject({ + success: true, + content: "hello world\n", + }); + expect(lsResult).toMatchObject({ + success: true, + content: expect.stringContaining("file\tsample.txt"), + }); + expect(commandResult).toMatchObject({ + success: false, + error: expect.stringContaining("Command not allowed: denied_command"), + }); + }); + + it("runs allowlisted protocol commands within the working directory", async () => { + const executor = new ToolExecutor(); + const commandResult = await executor.execute( + "run_command", + { command: "node -e console.log(process.cwd())" }, + tempDir, + ); + + expect(commandResult.success).toBe(true); + expect(commandResult.content.trim()).toBe(tempDir); + }); +}); diff --git a/packages/bridge/tests/hooks/event_queue.test.ts b/packages/bridge/tests/hooks/event_queue.test.ts new file mode 100644 index 0000000..756f3a9 --- /dev/null +++ b/packages/bridge/tests/hooks/event_queue.test.ts @@ -0,0 +1,49 @@ +import { EventQueue } from "../../src/hooks/event_queue"; +import type { BridgeMessage, NotificationPayload } from "../../src/types"; + +describe("EventQueue", () => { + it("replays queued messages without removing them", () => { + const queue = new EventQueue(); + const message: BridgeMessage = { + type: "claude_event", + id: "evt-1", + timestamp: new Date().toISOString(), + payload: { event_type: "SessionStart" }, + }; + + queue.enqueue(message, { sessionId: "sess-1" }); + + expect(queue.replay()).toEqual([message]); + expect(queue.size()).toBe(1); + }); + + it("acknowledges queued notifications by id", () => { + const queue = new EventQueue(); + const notification: BridgeMessage = { + type: "notification", + id: "notif-1", + timestamp: new Date().toISOString(), + payload: { + notification_id: "notif-1", + session_id: "sess-1", + notification_type: "approval_required", + title: "Approval needed", + body: "Review tool call", + priority: "high", + }, + }; + const toolResult: BridgeMessage = { + type: "tool_result", + id: "tool-1", + timestamp: new Date().toISOString(), + payload: { session_id: "sess-1" }, + }; + + queue.enqueue(notification, { sessionId: "sess-1", notificationId: "notif-1" }); + queue.enqueue(toolResult, { sessionId: "sess-1" }); + + queue.acknowledgeNotifications(["notif-1"]); + + expect(queue.replay()).toEqual([toolResult]); + }); +}); diff --git a/packages/bridge/tests/hooks/protocol_mapper.test.ts b/packages/bridge/tests/hooks/protocol_mapper.test.ts new file mode 100644 index 0000000..82d9371 --- /dev/null +++ b/packages/bridge/tests/hooks/protocol_mapper.test.ts @@ -0,0 +1,94 @@ +import { buildHookProtocolMessages } from "../../src/hooks/protocol_mapper"; +import type { HookEvent } from "../../src/types"; + +describe("buildHookProtocolMessages", () => { + it("maps PreToolUse hooks to approval_required messages", () => { + const event: HookEvent = { + event_type: "PreToolUse", + session_id: "sess-1", + timestamp: new Date().toISOString(), + payload: { + tool: "run_command", + params: { command: "flutter build apk" }, + description: "Build Android APK", + risk_level: "medium", + tool_call_id: "call-1", + }, + }; + + const messages = buildHookProtocolMessages(event); + const approvalRequired = messages.find((message) => message.type === "approval_required"); + + expect(messages[0]?.type).toBe("claude_event"); + expect(approvalRequired).toMatchObject({ + payload: { + session_id: "sess-1", + tool_call_id: "call-1", + tool: "run_command", + params: { command: "flutter build apk" }, + description: "Build Android APK", + risk_level: "medium", + source: "hooks", + }, + }); + }); + + it("maps PostToolUse hooks to tool_result messages", () => { + const event: HookEvent = { + event_type: "PostToolUse", + session_id: "sess-1", + timestamp: new Date().toISOString(), + payload: { + tool: "edit_file", + tool_call_id: "call-2", + result: { + success: true, + content: "File edited successfully", + diff: "@@ -1 +1 @@", + }, + }, + }; + + const messages = buildHookProtocolMessages(event); + const toolResult = messages.find((message) => message.type === "tool_result"); + + expect(toolResult).toMatchObject({ + payload: { + session_id: "sess-1", + tool_call_id: "call-2", + tool: "edit_file", + result: { + success: true, + content: "File edited successfully", + diff: "@@ -1 +1 @@", + }, + }, + }); + }); + + it("maps Notification hooks to notification messages with ack ids", () => { + const event: HookEvent = { + event_type: "Notification", + session_id: "sess-1", + timestamp: new Date().toISOString(), + payload: { + title: "Agent idle", + message: "Claude Code is waiting for input.", + }, + }; + + const messages = buildHookProtocolMessages(event); + const notification = messages.find((message) => message.type === "notification"); + + expect(notification).toMatchObject({ + payload: { + session_id: "sess-1", + notification_type: "agent_idle", + title: "Agent idle", + body: "Claude Code is waiting for input.", + priority: "normal", + }, + }); + expect(typeof notification?.id).toBe("string"); + }); +}); diff --git a/packages/bridge/tests/hooks/receiver.test.ts b/packages/bridge/tests/hooks/receiver.test.ts new file mode 100644 index 0000000..3bdd83f --- /dev/null +++ b/packages/bridge/tests/hooks/receiver.test.ts @@ -0,0 +1,250 @@ +import { createHooksRouter } from "../../src/hooks/receiver"; +import { EventQueue } from "../../src/hooks/event_queue"; +import type { ConnectionManager } from "../../src/websocket/connection_manager"; +import type { BridgeMessage } from "../../src/types"; +import express from "express"; +import request from "supertest"; + +function createMockConnectionManager(): { + manager: jest.Mocked; + broadcastMessages: BridgeMessage[]; +} { + const broadcastMessages: BridgeMessage[] = []; + const manager = { + sendToClient: jest.fn(), + authenticateClient: jest.fn(), + getClient: jest.fn(), + addSessionToClient: jest.fn(), + getClientsForSession: jest.fn(() => []), + broadcast: jest.fn((message: BridgeMessage) => { + broadcastMessages.push(message); + }), + }; + return { manager: manager as never, broadcastMessages }; +} + +describe("createHooksRouter", () => { + let eventQueue: EventQueue; + let mockConnectionManager: jest.Mocked; + let broadcastMessages: BridgeMessage[]; + let app: express.Application; + + beforeEach(() => { + eventQueue = new EventQueue(); + const result = createMockConnectionManager(); + mockConnectionManager = result.manager; + broadcastMessages = result.broadcastMessages; + + app = express(); + app.use(express.json()); + app.use("/hooks", createHooksRouter(eventQueue, mockConnectionManager)); + }); + + describe("POST /hooks/event", () => { + it("should accept valid hook events with valid token", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { working_directory: "/repo/project" }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + it("should reject requests without token", async () => { + const response = await request(app).post("/hooks/event").send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: {}, + }); + + expect(response.status).toBe(401); + }); + + it("should reject requests with invalid token", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer invalid-token") + .send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: {}, + }); + + expect(response.status).toBe(401); + }); + + it("should reject invalid hook event shape", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "InvalidEventType", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: {}, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("Bad Request"); + }); + + it("should reject stale timestamps", async () => { + const staleTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: staleTimestamp, + payload: {}, + }); + + expect(response.status).toBe(400); + }); + + it("should enqueue events after validation", async () => { + const initialSize = eventQueue.size(); + + await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { working_directory: "/repo/project" }, + }); + + expect(eventQueue.size()).toBe(initialSize + 1); + }); + + it("should broadcast events to connected clients", async () => { + await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "SessionStart", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { working_directory: "/repo/project" }, + }); + + expect(mockConnectionManager.broadcast).toHaveBeenCalled(); + }); + + it("should handle UserPromptSubmit events", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "UserPromptSubmit", + session_id: "sess-456", + timestamp: new Date().toISOString(), + payload: { + prompt: "Write a function that sorts an array", + }, + }); + + expect(response.status).toBe(200); + }); + + it("should handle PostToolUse events", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "PostToolUse", + session_id: "sess-789", + timestamp: new Date().toISOString(), + payload: { + tool: "read", + params: { path: "/src/index.ts" }, + result: "file contents", + }, + }); + + expect(response.status).toBe(200); + }); + + it("should handle PreToolUse events", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "PreToolUse", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { + tool: "write", + params: { path: "/src/new.ts" }, + }, + }); + + expect(response.status).toBe(200); + }); + + it("should handle SessionEnd events", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "SessionEnd", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { + reason: "completed", + }, + }); + + expect(response.status).toBe(200); + }); + + it("should handle Notification events", async () => { + const response = await request(app) + .post("/hooks/event") + .set("Authorization", "Bearer test-hook-token") + .send({ + event_type: "Notification", + session_id: "sess-123", + timestamp: new Date().toISOString(), + payload: { + notification_type: "info", + title: "Test notification", + body: "Test message", + }, + }); + + expect(response.status).toBe(200); + }); + }); + + describe("session routing", () => { + it("should route events by session_id", async () => { + await request(app).post("/hooks/event").set("Authorization", "Bearer test-hook-token").send({ + event_type: "SessionStart", + session_id: "specific-session-123", + timestamp: new Date().toISOString(), + payload: {}, + }); + + const messages = eventQueue.replay(); + expect(messages).toHaveLength(1); + + // Replay with session ID filter + const sessionMessages = eventQueue.replay("specific-session-123"); + expect(sessionMessages).toHaveLength(1); + + const otherMessages = eventQueue.replay("other-session"); + expect(otherMessages).toHaveLength(0); + }); + }); +}); diff --git a/packages/bridge/tests/hooks/validator.test.ts b/packages/bridge/tests/hooks/validator.test.ts new file mode 100644 index 0000000..c412eec --- /dev/null +++ b/packages/bridge/tests/hooks/validator.test.ts @@ -0,0 +1,28 @@ +import { validateHookEvent } from "../../src/hooks/validator"; +import type { HookEvent } from "../../src/types"; + +function createEvent(eventType: string, timestamp = new Date().toISOString()): HookEvent { + return { + event_type: eventType, + session_id: "sess-123", + timestamp, + payload: { example: true }, + }; +} + +describe("validateHookEvent", () => { + it("accepts documented hook events", () => { + expect(validateHookEvent(createEvent("SessionStart"))).toBe(true); + expect(validateHookEvent(createEvent("UserPromptSubmit"))).toBe(true); + expect(validateHookEvent(createEvent("PostToolUse"))).toBe(true); + }); + + it("rejects unsupported event types", () => { + expect(validateHookEvent(createEvent("UnknownEvent"))).toBe(false); + }); + + it("rejects stale timestamps", () => { + const staleTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + expect(validateHookEvent(createEvent("SessionEnd", staleTimestamp))).toBe(false); + }); +}); diff --git a/packages/bridge/tests/jest.setup.ts b/packages/bridge/tests/jest.setup.ts new file mode 100644 index 0000000..9057fcf --- /dev/null +++ b/packages/bridge/tests/jest.setup.ts @@ -0,0 +1,5 @@ +process.env.BRIDGE_TOKEN = "test-bridge-token"; +process.env.HOOK_TOKEN = "test-hook-token"; +process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; +process.env.AGENT_MODEL = "claude-opus-4-6"; +process.env.ALLOWED_PROJECT_ROOT = process.cwd(); diff --git a/packages/bridge/tests/notifications/dispatcher.test.ts b/packages/bridge/tests/notifications/dispatcher.test.ts new file mode 100644 index 0000000..150da9f --- /dev/null +++ b/packages/bridge/tests/notifications/dispatcher.test.ts @@ -0,0 +1,113 @@ +import { EventQueue } from "../../src/hooks/event_queue"; +import { Dispatcher } from "../../src/notifications/dispatcher"; +import { eventBus } from "../../src/notifications/event_bus"; +import type { BridgeMessage, MobileClient } from "../../src/types"; + +describe("Dispatcher", () => { + afterEach(() => { + eventBus.removeAllListeners(); + }); + + it("forwards stream lifecycle, approval, and tool result events to session clients", () => { + const sentMessages: BridgeMessage[] = []; + const eventQueue = new EventQueue(); + const connectionManager = { + getClientsForSession: jest.fn((_sessionId: string): MobileClient[] => [ + { + id: "client-1", + authenticated: true, + sessionIds: ["sess-1"], + ws: {} as never, + }, + ]), + sendToClient: jest.fn((_clientId: string, message: BridgeMessage) => { + sentMessages.push(message); + }), + broadcast: jest.fn(), + }; + + new Dispatcher(connectionManager as never, eventQueue); + + eventBus.emitTyped("session-event", { + type: "stream_start", + session_id: "sess-1", + message_id: "msg-1", + }); + eventBus.emitTyped("stream-chunk", { + session_id: "sess-1", + message_id: "msg-1", + content: "Hello", + }); + eventBus.emitTyped("tool-event", { + type: "approval_required", + session_id: "sess-1", + tool_call_id: "tool-1", + tool: "run_command", + params: { command: "npm test" }, + description: "Run tests", + risk_level: "medium", + source: "agent_sdk", + }); + eventBus.emitTyped("tool-event", { + type: "tool_result", + session_id: "sess-1", + tool_call_id: "tool-1", + tool: "run_command", + result: { + success: true, + content: "Tests passed", + duration_ms: 42, + }, + }); + eventBus.emitTyped("session-event", { + type: "stream_end", + session_id: "sess-1", + message_id: "msg-1", + finish_reason: "stop", + }); + + expect(sentMessages.map((message) => message.type)).toEqual([ + "stream_start", + "stream_chunk", + "approval_required", + "tool_result", + "stream_end", + ]); + expect(sentMessages[1]).toMatchObject({ + payload: { + session_id: "sess-1", + message_id: "msg-1", + content: "Hello", + is_tool_use: false, + }, + }); + expect(sentMessages[2]).toMatchObject({ + payload: { + tool: "run_command", + params: { command: "npm test" }, + source: "agent_sdk", + }, + }); + expect(sentMessages[3]).toMatchObject({ + payload: { + tool: "run_command", + result: { + success: true, + content: "Tests passed", + duration_ms: 42, + }, + }, + }); + expect(eventQueue.size()).toBe(1); + expect(eventQueue.replay("sess-1")).toEqual([ + expect.objectContaining({ + type: "tool_result", + payload: expect.objectContaining({ + session_id: "sess-1", + tool_call_id: "tool-1", + tool: "run_command", + }), + }), + ]); + }); +}); diff --git a/packages/bridge/tests/websocket/connection_manager.test.ts b/packages/bridge/tests/websocket/connection_manager.test.ts new file mode 100644 index 0000000..7965438 --- /dev/null +++ b/packages/bridge/tests/websocket/connection_manager.test.ts @@ -0,0 +1,287 @@ +import { ConnectionManager } from "../../src/websocket/connection_manager"; +import type { MobileClient, BridgeMessage } from "../../src/types"; + +function createMockWebSocket(readyState: number = 1): { + ws: { readyState: number; send: jest.Mock }; +} { + return { + ws: { + readyState, + send: jest.fn(), + }, + }; +} + +describe("ConnectionManager", () => { + let connectionManager: ConnectionManager; + + beforeEach(() => { + connectionManager = new ConnectionManager(); + }); + + describe("addClient", () => { + it("should add a client with unauthenticated state", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + + const client = connectionManager.getClient("client-1"); + expect(client).toBeDefined(); + expect(client?.id).toBe("client-1"); + expect(client?.authenticated).toBe(false); + expect(client?.sessionIds).toEqual([]); + }); + + it("should allow adding multiple clients", () => { + const { ws: ws1 } = createMockWebSocket(); + const { ws: ws2 } = createMockWebSocket(); + + connectionManager.addClient("client-1", ws1 as never); + connectionManager.addClient("client-2", ws2 as never); + + expect(connectionManager.getClient("client-1")).toBeDefined(); + expect(connectionManager.getClient("client-2")).toBeDefined(); + }); + }); + + describe("authenticateClient", () => { + it("should mark an existing client as authenticated", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + connectionManager.authenticateClient("client-1"); + + const client = connectionManager.getClient("client-1"); + expect(client?.authenticated).toBe(true); + }); + + it("should do nothing for non-existent client", () => { + // Should not throw + expect(() => connectionManager.authenticateClient("unknown-client")).not.toThrow(); + }); + }); + + describe("removeClient", () => { + it("should remove an existing client", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + connectionManager.removeClient("client-1"); + + expect(connectionManager.getClient("client-1")).toBeUndefined(); + }); + + it("should do nothing for non-existent client", () => { + // Should not throw + expect(() => connectionManager.removeClient("unknown-client")).not.toThrow(); + }); + }); + + describe("getClient", () => { + it("should return undefined for non-existent client", () => { + expect(connectionManager.getClient("unknown")).toBeUndefined(); + }); + + it("should return the client when it exists", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + + const client = connectionManager.getClient("client-1"); + expect(client?.id).toBe("client-1"); + }); + }); + + describe("addSessionToClient", () => { + it("should add a session to client's session list", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + connectionManager.addSessionToClient("client-1", "sess-1"); + + const client = connectionManager.getClient("client-1"); + expect(client?.sessionIds).toContain("sess-1"); + }); + + it("should not duplicate sessions", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + connectionManager.addSessionToClient("client-1", "sess-1"); + connectionManager.addSessionToClient("client-1", "sess-1"); + + const client = connectionManager.getClient("client-1"); + expect(client?.sessionIds.filter((id) => id === "sess-1")).toHaveLength(1); + }); + + it("should do nothing for non-existent client", () => { + // Should not throw + expect(() => connectionManager.addSessionToClient("unknown", "sess-1")).not.toThrow(); + }); + }); + + describe("getClientsForSession", () => { + it("should return clients subscribed to the session", () => { + const { ws: ws1 } = createMockWebSocket(); + const { ws: ws2 } = createMockWebSocket(); + + connectionManager.addClient("client-1", ws1 as never); + connectionManager.addClient("client-2", ws2 as never); + + connectionManager.authenticateClient("client-1"); + connectionManager.authenticateClient("client-2"); + + connectionManager.addSessionToClient("client-1", "sess-1"); + connectionManager.addSessionToClient("client-2", "sess-1"); + + const clients = connectionManager.getClientsForSession("sess-1"); + expect(clients).toHaveLength(2); + expect(clients.map((c) => c.id)).toContain("client-1"); + expect(clients.map((c) => c.id)).toContain("client-2"); + }); + + it("should not return unauthenticated clients", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + // Not authenticated + connectionManager.addSessionToClient("client-1", "sess-1"); + + const clients = connectionManager.getClientsForSession("sess-1"); + expect(clients).toHaveLength(0); + }); + + it("should return empty array for session with no subscribers", () => { + const clients = connectionManager.getClientsForSession("unknown-session"); + expect(clients).toEqual([]); + }); + }); + + describe("broadcast", () => { + it("should send message to all authenticated clients", () => { + const { ws: ws1 } = createMockWebSocket(); + const { ws: ws2 } = createMockWebSocket(); + + connectionManager.addClient("client-1", ws1 as never); + connectionManager.addClient("client-2", ws2 as never); + + connectionManager.authenticateClient("client-1"); + connectionManager.authenticateClient("client-2"); + + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + connectionManager.broadcast(message); + + expect(ws1.send).toHaveBeenCalledTimes(1); + expect(ws2.send).toHaveBeenCalledTimes(1); + }); + + it("should not send to unauthenticated clients", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + // Not authenticated + + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + connectionManager.broadcast(message); + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("should respect filter function", () => { + const { ws: ws1 } = createMockWebSocket(); + const { ws: ws2 } = createMockWebSocket(); + + connectionManager.addClient("client-1", ws1 as never); + connectionManager.addClient("client-2", ws2 as never); + + connectionManager.authenticateClient("client-1"); + connectionManager.authenticateClient("client-2"); + + connectionManager.addSessionToClient("client-1", "sess-1"); + connectionManager.addSessionToClient("client-2", "sess-2"); + + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + // Only send to clients subscribed to sess-1 + connectionManager.broadcast(message, (client) => client.sessionIds.includes("sess-1")); + + expect(ws1.send).toHaveBeenCalledTimes(1); + expect(ws2.send).not.toHaveBeenCalled(); + }); + + it("should not send to clients with closed WebSocket", () => { + const { ws: ws1 } = createMockWebSocket(1); // OPEN + const { ws: ws2 } = createMockWebSocket(3); // CLOSED + + connectionManager.addClient("client-1", ws1 as never); + connectionManager.addClient("client-2", ws2 as never); + + connectionManager.authenticateClient("client-1"); + connectionManager.authenticateClient("client-2"); + + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + connectionManager.broadcast(message); + + expect(ws1.send).toHaveBeenCalledTimes(1); + expect(ws2.send).not.toHaveBeenCalled(); + }); + }); + + describe("sendToClient", () => { + it("should send message to specific client", () => { + const { ws } = createMockWebSocket(); + connectionManager.addClient("client-1", ws as never); + connectionManager.authenticateClient("client-1"); + + const message: BridgeMessage = { + type: "test_event", + id: "msg-1", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + connectionManager.sendToClient("client-1", message); + + expect(ws.send).toHaveBeenCalledTimes(1); + const sentData = JSON.parse(ws.send.mock.calls[0][0]); + expect(sentData.type).toBe("test_event"); + expect(sentData.id).toBe("msg-1"); + }); + + it("should do nothing for non-existent client", () => { + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + // Should not throw + expect(() => connectionManager.sendToClient("unknown", message)).not.toThrow(); + }); + + it("should not send if WebSocket is not open", () => { + const { ws } = createMockWebSocket(0); // CONNECTING + connectionManager.addClient("client-1", ws as never); + connectionManager.authenticateClient("client-1"); + + const message: BridgeMessage = { + type: "test_event", + timestamp: new Date().toISOString(), + payload: { data: "test" }, + }; + + connectionManager.sendToClient("client-1", message); + expect(ws.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/bridge/tests/websocket/message_handler.test.ts b/packages/bridge/tests/websocket/message_handler.test.ts new file mode 100644 index 0000000..1becc1a --- /dev/null +++ b/packages/bridge/tests/websocket/message_handler.test.ts @@ -0,0 +1,255 @@ +import { EventQueue } from "../../src/hooks/event_queue"; +import { Dispatcher } from "../../src/notifications/dispatcher"; +import { eventBus } from "../../src/notifications/event_bus"; +import { MessageHandler } from "../../src/websocket/message_handler"; +import type { + AgentSession, + BridgeMessage, + NotificationPayload, + SessionStartPayload, +} from "../../src/types"; + +describe("MessageHandler", () => { + afterEach(() => { + eventBus.removeAllListeners(); + }); + + function createDependencies() { + const sentMessages: Array<{ clientId: string; message: BridgeMessage }> = []; + const connectionManager = { + sendToClient: jest.fn((clientId: string, message: BridgeMessage) => { + sentMessages.push({ clientId, message }); + }), + authenticateClient: jest.fn(), + getClient: jest.fn(), + addSessionToClient: jest.fn(), + getClientsForSession: jest.fn(), + broadcast: jest.fn(), + }; + + const agentSdkAdapter = { + handleSessionStart: jest.fn(), + handleMessage: jest.fn(), + handleApprovalResponse: jest.fn(), + handleSessionEnd: jest.fn(), + }; + + const activeSession: AgentSession = { + id: "sess-1", + agent: "claude-code", + title: "project", + model: "claude-opus-4-6", + working_directory: "/repo/project", + created_at: new Date().toISOString(), + status: "idle", + }; + + const agentSessionManager = { + getActiveSessions: jest.fn(() => [activeSession]), + }; + + const gitService = { + getStatus: jest.fn(), + commit: jest.fn(), + getDiff: jest.fn(), + }; + + const queuedNotification: BridgeMessage = { + type: "notification", + id: "notif-1", + timestamp: new Date().toISOString(), + payload: { + notification_id: "notif-1", + session_id: "sess-1", + notification_type: "approval_required", + title: "Approval needed", + body: "Review tool call", + priority: "high", + }, + }; + + const eventQueue = { + replay: jest.fn(() => [queuedNotification]), + acknowledgeNotifications: jest.fn(), + }; + + const handler = new MessageHandler( + connectionManager as never, + agentSdkAdapter as never, + agentSessionManager as never, + gitService as never, + eventQueue as never, + ); + + return { + handler, + sentMessages, + connectionManager, + agentSdkAdapter, + activeSession, + eventQueue, + }; + } + + it("sends a contract-aligned connection_ack and replays queued events on auth", async () => { + const { handler, sentMessages, connectionManager, activeSession } = createDependencies(); + + await handler.handle( + "client-1", + JSON.stringify({ + type: "auth", + id: "auth-1", + timestamp: new Date().toISOString(), + payload: { + token: "test-bridge-token", + client_version: "1.0.0", + platform: "ios", + }, + }), + ); + + expect(connectionManager.authenticateClient).toHaveBeenCalledWith("client-1"); + expect(sentMessages[0]?.message).toMatchObject({ + type: "connection_ack", + id: "auth-1", + timestamp: expect.any(String), + payload: { + server_version: "0.1.0", + supported_agents: ["claude-code"], + active_sessions: [ + { + session_id: activeSession.id, + agent: "claude-code", + title: activeSession.title, + working_directory: activeSession.working_directory, + status: activeSession.status, + }, + ], + }, + }); + expect(sentMessages[1]?.message.type).toBe("notification"); + }); + + it("passes the original request id through session_start handling", async () => { + const { handler, connectionManager, agentSdkAdapter } = createDependencies(); + connectionManager.getClient.mockReturnValue({ authenticated: true, sessionIds: [] }); + + const payload: SessionStartPayload = { + agent: "claude-code", + working_directory: "/repo/project", + resume: false, + }; + + await handler.handle( + "client-1", + JSON.stringify({ + type: "session_start", + id: "req-1", + timestamp: new Date().toISOString(), + payload, + }), + ); + + expect(agentSdkAdapter.handleSessionStart).toHaveBeenCalledWith(payload, "client-1", "req-1"); + }); + + it("acknowledges notification ids from the client", async () => { + const { handler, connectionManager, eventQueue } = createDependencies(); + connectionManager.getClient.mockReturnValue({ authenticated: true, sessionIds: [] }); + + await handler.handle( + "client-1", + JSON.stringify({ + type: "notification_ack", + timestamp: new Date().toISOString(), + payload: { + notification_ids: ["notif-1", "notif-2"], + }, + }), + ); + + expect(eventQueue.acknowledgeNotifications).toHaveBeenCalledWith(["notif-1", "notif-2"]); + }); + + it("replays queued agent-sdk tool results after auth reconnect", async () => { + const sentMessages: Array<{ clientId: string; message: BridgeMessage }> = []; + const eventQueue = new EventQueue(); + const connectionManager = { + sendToClient: jest.fn((clientId: string, message: BridgeMessage) => { + sentMessages.push({ clientId, message }); + }), + authenticateClient: jest.fn(), + getClient: jest.fn(), + addSessionToClient: jest.fn(), + getClientsForSession: jest.fn(() => []), + broadcast: jest.fn(), + }; + const agentSdkAdapter = { + handleSessionStart: jest.fn(), + handleMessage: jest.fn(), + handleApprovalResponse: jest.fn(), + handleSessionEnd: jest.fn(), + }; + const agentSessionManager = { + getActiveSessions: jest.fn(() => []), + }; + const gitService = { + getStatus: jest.fn(), + commit: jest.fn(), + getDiff: jest.fn(), + }; + + new Dispatcher(connectionManager as never, eventQueue); + eventBus.emitTyped("tool-event", { + type: "tool_result", + session_id: "sess-1", + tool_call_id: "tool-1", + tool: "run_command", + result: { + success: true, + content: "Tests passed", + duration_ms: 42, + }, + }); + + const handler = new MessageHandler( + connectionManager as never, + agentSdkAdapter as never, + agentSessionManager as never, + gitService as never, + eventQueue, + ); + + await handler.handle( + "client-1", + JSON.stringify({ + type: "auth", + id: "auth-reconnect-1", + timestamp: new Date().toISOString(), + payload: { + token: "test-bridge-token", + client_version: "1.0.0", + platform: "ios", + }, + }), + ); + + expect(sentMessages.map((entry) => entry.message.type)).toEqual([ + "connection_ack", + "tool_result", + ]); + expect(sentMessages[1]?.message).toMatchObject({ + type: "tool_result", + payload: { + session_id: "sess-1", + tool_call_id: "tool-1", + tool: "run_command", + result: { + success: true, + content: "Tests passed", + duration_ms: 42, + }, + }, + }); + }); +}); diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md new file mode 100644 index 0000000..a9e9628 --- /dev/null +++ b/packages/claude-plugin/README.md @@ -0,0 +1,40 @@ +# ReCursor Claude Code Plugin + +Forwards Claude Code events to the ReCursor bridge server for mobile consumption. + +## Installation + +Copy this plugin to your Claude Code plugins directory: + +```bash +mkdir -p ~/.claude-code/plugins/recursor-bridge +cp hooks.json ~/.claude-code/plugins/recursor-bridge/hooks.json +``` + +## Configuration + +Set environment variables before running Claude Code: + +```bash +export RECURSOR_BRIDGE_URL=http://100.78.42.15:3000 # Your bridge server URL (Tailscale IP) +export RECURSOR_HOOK_TOKEN=your-hook-token-here # Matches HOOK_TOKEN in bridge .env +``` + +Or add them to your shell profile (`~/.zshrc`, `~/.bashrc`). + +## Events Forwarded + +- `SessionStart` — New Claude Code session begins +- `SessionEnd` — Session terminates +- `PreToolUse` — Agent about to use a tool +- `PostToolUse` — Tool execution completed +- `UserPromptSubmit` — User submits a prompt +- `Stop` — Agent stops execution +- `SubagentStop` — Subagent stops +- `Notification` — System notification + +## Security + +- Hook commands use `|| true` so failures do not block Claude Code operation +- The bridge validates the `RECURSOR_HOOK_TOKEN` before processing events +- Use HTTPS (`https://`) for the bridge URL in production (Tailscale provides encryption) diff --git a/packages/claude-plugin/hooks.json b/packages/claude-plugin/hooks.json new file mode 100644 index 0000000..b1d7bb4 --- /dev/null +++ b/packages/claude-plugin/hooks.json @@ -0,0 +1,93 @@ +{ + "description": "ReCursor bridge integration — forward Claude Code events to mobile app", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -s -X POST \"${RECURSOR_BRIDGE_URL:-http://localhost:3000}/hooks/event\" -H 'Content-Type: application/json' -H \"Authorization: Bearer ${RECURSOR_HOOK_TOKEN}\" -d @- || true", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/tool/bridge-check-and-run.ps1 b/tool/bridge-check-and-run.ps1 new file mode 100644 index 0000000..b76f206 --- /dev/null +++ b/tool/bridge-check-and-run.ps1 @@ -0,0 +1,99 @@ +param( + [switch]$SkipInstall, + [switch]$SkipRun, + [switch]$AllFiles, + [ValidateSet('dev', 'start')] + [string]$RunScript = 'dev' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Executable, + [string[]]$Arguments = @() + ) + + Write-Host "==> $Name" -ForegroundColor Cyan + & $Executable @Arguments + + if ($LASTEXITCODE -ne 0) { + throw "$Name failed with exit code $LASTEXITCODE." + } +} + +function Get-ChangedBridgeFiles { + param( + [Parameter(Mandatory = $true)] + [string]$RepositoryRoot + ) + + $trackedOutput = & git -C $RepositoryRoot diff --name-only --diff-filter=ACMR + if ($LASTEXITCODE -ne 0) { + throw 'Unable to read tracked bridge file changes from git.' + } + + $untrackedOutput = & git -C $RepositoryRoot ls-files --others --exclude-standard + if ($LASTEXITCODE -ne 0) { + throw 'Unable to read untracked bridge file changes from git.' + } + + $files = @($trackedOutput + $untrackedOutput) | + Where-Object { + $_ -match '^packages/bridge/' -and + $_ -notmatch '^packages/bridge/(dist|node_modules)/' -and + $_ -match '\.(ts|js|cjs|mjs|json)$' + } | + Sort-Object -Unique + + return @($files) +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$bridgeDir = Join-Path $repoRoot 'packages/bridge' +$nodeModulesDir = Join-Path $bridgeDir 'node_modules' +$changedFiles = @() + +if (-not $AllFiles) { + $changedFiles = @(Get-ChangedBridgeFiles -RepositoryRoot $repoRoot) +} + +$relativeChangedFiles = $changedFiles | ForEach-Object { $_ -replace '^packages/bridge/', '' } + +Push-Location $bridgeDir +try { + if (-not $SkipInstall -and -not (Test-Path $nodeModulesDir)) { + Invoke-Step -Name 'npm ci' -Executable 'npm' -Arguments @('ci') + } + elseif (-not $SkipInstall) { + Write-Host 'node_modules already exists; skipping npm ci. Use a clean install manually when needed.' -ForegroundColor Yellow + } + + if ($AllFiles) { + Invoke-Step -Name 'npm run format' -Executable 'npm' -Arguments @('run', 'format') + } + elseif ($changedFiles.Count -gt 0) { + Invoke-Step -Name 'prettier changed files' -Executable 'npm' -Arguments (@('exec', 'prettier', '--', '--write') + $relativeChangedFiles) + } + else { + Write-Host 'No changed bridge files detected; skipping prettier. Use -AllFiles for a project-wide pass.' -ForegroundColor Yellow + } + + Invoke-Step -Name 'npm run typecheck' -Executable 'npm' -Arguments @('run', 'typecheck') + Invoke-Step -Name 'npm test -- --passWithNoTests --runInBand' -Executable 'npm' -Arguments @('test', '--', '--passWithNoTests', '--runInBand') + Invoke-Step -Name 'npm run build' -Executable 'npm' -Arguments @('run', 'build') + + if ($SkipRun) { + Write-Host 'Skipping npm run because -SkipRun was provided.' -ForegroundColor Yellow + return + } + + Invoke-Step -Name "npm run $RunScript" -Executable 'npm' -Arguments @('run', $RunScript) +} +finally { + Pop-Location +} diff --git a/tool/bridge-check-and-run.sh b/tool/bridge-check-and-run.sh new file mode 100644 index 0000000..ab613e2 --- /dev/null +++ b/tool/bridge-check-and-run.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +SKIP_INSTALL=false +SKIP_RUN=false +ALL_FILES=false +RUN_SCRIPT="dev" + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-install) + SKIP_INSTALL=true + shift + ;; + --skip-run) + SKIP_RUN=true + shift + ;; + --all-files) + ALL_FILES=true + shift + ;; + --run-script) + RUN_SCRIPT="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +run_step() { + local name="$1" + shift + echo "==> $name" + "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BRIDGE_DIR="$REPO_ROOT/packages/bridge" +NODE_MODULES_DIR="$BRIDGE_DIR/node_modules" + +mapfile -t CHANGED_FILES < <( + if [[ "$ALL_FILES" == true ]]; then + true + else + cd "$REPO_ROOT" + { + git diff --name-only --diff-filter=ACMR + git ls-files --others --exclude-standard + } | grep -E '^packages/bridge/' \ + | grep -Ev '^packages/bridge/(dist|node_modules)/' \ + | grep -E '\.(ts|js|cjs|mjs|json)$' \ + | sort -u || true + fi +) + +RELATIVE_CHANGED_FILES=() +for file in "${CHANGED_FILES[@]}"; do + RELATIVE_CHANGED_FILES+=("${file#packages/bridge/}") +done + +cd "$BRIDGE_DIR" + +if [[ "$SKIP_INSTALL" != true && ! -d "$NODE_MODULES_DIR" ]]; then + run_step "npm ci" npm ci +elif [[ "$SKIP_INSTALL" != true ]]; then + echo "node_modules already exists; skipping npm ci. Use a clean install manually when needed." +fi + +if [[ "$ALL_FILES" == true ]]; then + run_step "npm run format" npm run format +elif [[ ${#CHANGED_FILES[@]} -gt 0 ]]; then + run_step "prettier changed files" npm exec prettier -- --write "${RELATIVE_CHANGED_FILES[@]}" +else + echo "No changed bridge files detected; skipping prettier. Use --all-files for a project-wide pass." +fi + +run_step "npm run typecheck" npm run typecheck +run_step "npm test -- --passWithNoTests --runInBand" npm test -- --passWithNoTests --runInBand +run_step "npm run build" npm run build + +if [[ "$SKIP_RUN" == true ]]; then + echo "Skipping npm run because --skip-run was provided." + exit 0 +fi + +run_step "npm run $RUN_SCRIPT" npm run "$RUN_SCRIPT" diff --git a/tool/install-git-hooks.ps1 b/tool/install-git-hooks.ps1 new file mode 100644 index 0000000..22fbd68 --- /dev/null +++ b/tool/install-git-hooks.ps1 @@ -0,0 +1,12 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +Push-Location $repoRoot +try { + git config core.hooksPath .githooks + Write-Host 'Configured git hooks path to .githooks' -ForegroundColor Green +} +finally { + Pop-Location +} diff --git a/tool/install-git-hooks.sh b/tool/install-git-hooks.sh new file mode 100644 index 0000000..178835e --- /dev/null +++ b/tool/install-git-hooks.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" +git config core.hooksPath .githooks + +echo "Configured git hooks path to .githooks" diff --git a/tool/mobile-check-and-run.ps1 b/tool/mobile-check-and-run.ps1 new file mode 100644 index 0000000..123f462 --- /dev/null +++ b/tool/mobile-check-and-run.ps1 @@ -0,0 +1,120 @@ +param( + [string]$DeviceId = '', + [switch]$SkipPubGet, + [switch]$SkipRun, + [switch]$StrictAnalyze, + [switch]$ApplyProjectFixes, + [switch]$AllFiles, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$FlutterRunArgs = @() +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-Step { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Executable, + [string[]]$Arguments = @() + ) + + Write-Host "==> $Name" -ForegroundColor Cyan + & $Executable @Arguments + + if ($LASTEXITCODE -ne 0) { + throw "$Name failed with exit code $LASTEXITCODE." + } +} + +function Get-ChangedDartFiles { + param( + [Parameter(Mandatory = $true)] + [string]$RepositoryRoot + ) + + $trackedOutput = & git -C $RepositoryRoot diff --name-only --diff-filter=ACMR -- ':(glob)apps/mobile/**/*.dart' + if ($LASTEXITCODE -ne 0) { + throw 'Unable to read tracked Dart file changes from git.' + } + + $untrackedOutput = & git -C $RepositoryRoot ls-files --others --exclude-standard -- ':(glob)apps/mobile/**/*.dart' + if ($LASTEXITCODE -ne 0) { + throw 'Unable to read untracked Dart file changes from git.' + } + + $files = @($trackedOutput + $untrackedOutput) | + Where-Object { $_ -and $_ -notmatch '\.(g|freezed)\.dart$' } | + Sort-Object -Unique + + return @($files) +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$appDir = Join-Path $repoRoot 'apps/mobile' +$changedDartFiles = @() + +if (-not $AllFiles) { + $changedDartFiles = @(Get-ChangedDartFiles -RepositoryRoot $repoRoot) +} + +$relativeChangedDartFiles = $changedDartFiles | ForEach-Object { $_ -replace '^apps/mobile/', '' } + +Push-Location $appDir +try { + if (-not $SkipPubGet) { + Invoke-Step -Name 'flutter pub get' -Executable 'flutter' -Arguments @('pub', 'get') + } + + if ($AllFiles) { + Invoke-Step -Name 'dart format .' -Executable 'dart' -Arguments @('format', '.') + } + elseif ($changedDartFiles.Count -gt 0) { + Invoke-Step -Name 'dart format changed files' -Executable 'dart' -Arguments (@('format') + $relativeChangedDartFiles) + } + else { + Write-Host 'No changed Dart files detected; skipping dart format. Use -AllFiles for a project-wide pass.' -ForegroundColor Yellow + } + + if ($ApplyProjectFixes) { + Invoke-Step -Name 'dart fix --apply' -Executable 'dart' -Arguments @('fix', '--apply') + } + else { + Invoke-Step -Name 'dart fix --dry-run' -Executable 'dart' -Arguments @('fix', '--dry-run') + } + + $analyzeArguments = @('analyze') + if (-not $StrictAnalyze) { + $analyzeArguments += @('--no-fatal-infos', '--no-fatal-warnings') + } + + if ($AllFiles) { + Invoke-Step -Name 'flutter analyze' -Executable 'flutter' -Arguments $analyzeArguments + } + elseif ($changedDartFiles.Count -gt 0) { + Invoke-Step -Name 'flutter analyze changed files' -Executable 'flutter' -Arguments ($analyzeArguments + $relativeChangedDartFiles) + } + else { + Write-Host 'No changed Dart files detected; skipping flutter analyze. Use -AllFiles for a project-wide pass.' -ForegroundColor Yellow + } + + if ($SkipRun) { + Write-Host 'Skipping flutter run because -SkipRun was provided.' -ForegroundColor Yellow + return + } + + $runArguments = @('run') + if ($DeviceId) { + $runArguments += @('-d', $DeviceId) + } + if ($FlutterRunArgs.Count -gt 0) { + $runArguments += $FlutterRunArgs + } + + Invoke-Step -Name 'flutter run' -Executable 'flutter' -Arguments $runArguments +} +finally { + Pop-Location +} diff --git a/tool/mobile-check-and-run.sh b/tool/mobile-check-and-run.sh new file mode 100644 index 0000000..20895f6 --- /dev/null +++ b/tool/mobile-check-and-run.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_ID="" +SKIP_PUB_GET=false +SKIP_RUN=false +STRICT_ANALYZE=false +APPLY_PROJECT_FIXES=false +ALL_FILES=false +RUN_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -d|--device) + DEVICE_ID="$2" + shift 2 + ;; + --skip-pub-get) + SKIP_PUB_GET=true + shift + ;; + --skip-run) + SKIP_RUN=true + shift + ;; + --strict-analyze) + STRICT_ANALYZE=true + shift + ;; + --apply-project-fixes) + APPLY_PROJECT_FIXES=true + shift + ;; + --all-files) + ALL_FILES=true + shift + ;; + --) + shift + RUN_ARGS=("$@") + break + ;; + *) + RUN_ARGS+=("$1") + shift + ;; + esac +done + +run_step() { + local name="$1" + shift + echo "==> $name" + "$@" +} + +map_changed_dart_files() { + git diff --name-only --diff-filter=ACMR -- ':(glob)apps/mobile/**/*.dart' + git ls-files --others --exclude-standard -- ':(glob)apps/mobile/**/*.dart' +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_DIR="$REPO_ROOT/apps/mobile" + +mapfile -t CHANGED_DART_FILES < <( + if [[ "$ALL_FILES" == true ]]; then + true + else + cd "$REPO_ROOT" + map_changed_dart_files | grep -E '^apps/mobile/.*\.dart$' | grep -Ev '\.(g|freezed)\.dart$' | sort -u || true + fi +) + +RELATIVE_CHANGED_DART_FILES=() +for file in "${CHANGED_DART_FILES[@]}"; do + RELATIVE_CHANGED_DART_FILES+=("${file#apps/mobile/}") +done + +cd "$APP_DIR" + +if [[ "$SKIP_PUB_GET" != true ]]; then + run_step "flutter pub get" flutter pub get +fi + +if [[ "$ALL_FILES" == true ]]; then + run_step "dart format ." dart format . +elif [[ ${#CHANGED_DART_FILES[@]} -gt 0 ]]; then + run_step "dart format changed files" dart format "${RELATIVE_CHANGED_DART_FILES[@]}" +else + echo "No changed Dart files detected; skipping dart format. Use --all-files for a project-wide pass." +fi + +if [[ "$APPLY_PROJECT_FIXES" == true ]]; then + run_step "dart fix --apply" dart fix --apply +else + run_step "dart fix --dry-run" dart fix --dry-run +fi + +ANALYZE_COMMAND=(flutter analyze) +if [[ "$STRICT_ANALYZE" != true ]]; then + ANALYZE_COMMAND+=(--no-fatal-infos --no-fatal-warnings) +fi + +if [[ "$ALL_FILES" == true ]]; then + run_step "flutter analyze" "${ANALYZE_COMMAND[@]}" +elif [[ ${#CHANGED_DART_FILES[@]} -gt 0 ]]; then + run_step "flutter analyze changed files" "${ANALYZE_COMMAND[@]}" "${RELATIVE_CHANGED_DART_FILES[@]}" +else + echo "No changed Dart files detected; skipping flutter analyze. Use --all-files for a project-wide pass." +fi + +if [[ "$SKIP_RUN" == true ]]; then + echo "Skipping flutter run because --skip-run was provided." + exit 0 +fi + +RUN_COMMAND=(flutter run) +if [[ -n "$DEVICE_ID" ]]; then + RUN_COMMAND+=(-d "$DEVICE_ID") +fi +if [[ ${#RUN_ARGS[@]} -gt 0 ]]; then + RUN_COMMAND+=("${RUN_ARGS[@]}") +fi + +run_step "flutter run" "${RUN_COMMAND[@]}"