diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 386867873c..7ec27bd2a8 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -74,6 +74,23 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail + xcode_path="$(python3 - <<'PY' + import glob + import re + + candidates = [] + for path in glob.glob("/Applications/Xcode_26*.app"): + match = re.search(r"Xcode_(26(?:\.\d+)*)", path) + if match: + version = tuple(int(part) for part in match.group(1).split(".")) + candidates.append((version, path)) + if not candidates: + raise SystemExit("Xcode 26 is not installed on this runner") + print(max(candidates)[1]) + PY + )" + export DEVELOPER_DIR="$xcode_path/Contents/Developer" + echo "DEVELOPER_DIR=$DEVELOPER_DIR" >> "$GITHUB_ENV" node scripts/ios-release/readiness.mjs config > "$RUNNER_TEMP/ios-release-config.json" xcodebuild -version major=$(xcodebuild -version | awk '/Xcode/{split($2,v,".");print v[1]}') @@ -87,6 +104,8 @@ jobs: command -v xcodegen >/dev/null || brew install xcodegen - name: Run mobile regression checks + env: + NODE_OPTIONS: --max-old-space-size=6144 run: | pnpm exec tsc --noEmit pnpm test src/modules/MobileRemote @@ -95,14 +114,160 @@ jobs: pnpm build:mobile-native cargo check --locked --manifest-path apps/remote-ios/src-tauri/Cargo.toml --target aarch64-apple-ios - - name: Build signed release with bundled frontend + - name: Install isolated iOS signing materials env: IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set +x + set -euo pipefail + umask 077 + + certificate_path="$RUNNER_TEMP/ios-distribution.p12" + profile_path="$RUNNER_TEMP/ios-app-store.mobileprovision" + profile_plist="$RUNNER_TEMP/ios-app-store.plist" + keychain_path="$RUNNER_TEMP/ios-signing.keychain-db" + keychain_password="$(uuidgen)$(uuidgen)" + echo "::add-mask::$keychain_password" + { + echo "IOS_KEYCHAIN_PATH=$keychain_path" + echo "IOS_KEYCHAIN_PASSWORD=$keychain_password" + } >> "$GITHUB_ENV" + + CERTIFICATE_PATH="$certificate_path" PROFILE_PATH="$profile_path" python3 - <<'PY' + import base64 + import os + from pathlib import Path + + for source, target in ( + ("IOS_CERTIFICATE", "CERTIFICATE_PATH"), + ("IOS_MOBILE_PROVISION", "PROFILE_PATH"), + ): + try: + encoded = "".join(os.environ[source].split()) + decoded = base64.b64decode(encoded, validate=True) + except Exception as error: + raise SystemExit(f"Invalid base64 signing material: {source}") from error + if not decoded: + raise SystemExit(f"Empty decoded signing material: {source}") + Path(os.environ[target]).write_bytes(decoded) + PY + + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 3600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$IOS_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -t cert \ + -T /usr/bin/codesign \ + -T /usr/bin/security >/dev/null + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" >/dev/null + security list-keychains -d user -s "$keychain_path" + IOS_KEYCHAIN_PATH="$keychain_path" IOS_SIGNING_IDENTITY_FILE="$RUNNER_TEMP/ios-signing-identity" python3 - <<'PY' + import os + import re + import subprocess + from pathlib import Path + + output = subprocess.check_output( + ["security", "find-identity", "-v", "-p", "codesigning", os.environ["IOS_KEYCHAIN_PATH"]], + text=True, + ) + identities = re.findall( + r'^\s*\d+\)\s+[A-Fa-f0-9]+\s+"((?:Apple|iPhone) Distribution: [^"]+)"\s*$', + output, + re.MULTILINE, + ) + if len(identities) != 1: + raise SystemExit("Expected exactly one Apple Distribution signing identity") + Path(os.environ["IOS_SIGNING_IDENTITY_FILE"]).write_text(identities[0], encoding="utf-8") + PY + + security cms -D -i "$profile_path" > "$profile_plist" + PROFILE_PLIST="$profile_plist" PROFILE_UUID_FILE="$RUNNER_TEMP/ios-profile-uuid" python3 - <<'PY' + import os + import plistlib + import re + from datetime import datetime, timezone + from pathlib import Path + + with open(os.environ["PROFILE_PLIST"], "rb") as source: + profile = plistlib.load(source) + team = os.environ["APPLE_TEAM_ID"] + bundle_id = "org2ai.org2.remote" + entitlements = profile.get("Entitlements", {}) + app_id = entitlements.get("application-identifier", "") + prefix, separator, profile_bundle_id = app_id.partition(".") + + if team not in profile.get("TeamIdentifier", []): + raise SystemExit("Provisioning profile Team ID mismatch") + if not separator or prefix not in profile.get("ApplicationIdentifierPrefix", []): + raise SystemExit("Provisioning profile application prefix mismatch") + if profile_bundle_id != bundle_id: + raise SystemExit("Provisioning profile bundle ID mismatch") + if profile.get("ProvisionedDevices") is not None or profile.get("ProvisionsAllDevices") is True: + raise SystemExit("Expected an App Store Connect provisioning profile") + if entitlements.get("get-task-allow") is not False: + raise SystemExit("Provisioning profile allows debugging") + if entitlements.get("beta-reports-active") is not True: + raise SystemExit("Provisioning profile is not for App Store distribution") + expiration = profile.get("ExpirationDate") + if not isinstance(expiration, datetime): + raise SystemExit("Provisioning profile expiration is missing") + if expiration.replace(tzinfo=expiration.tzinfo or timezone.utc) <= datetime.now(timezone.utc): + raise SystemExit("Provisioning profile has expired") + + uuid = profile.get("UUID", "") + if not re.fullmatch(r"[A-Fa-f0-9-]{36}", uuid): + raise SystemExit("Provisioning profile UUID is invalid") + Path(os.environ["PROFILE_UUID_FILE"]).write_text(uuid, encoding="utf-8") + PY + + profile_uuid="$(cat "$RUNNER_TEMP/ios-profile-uuid")" + profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + xcode_profiles_dir="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + installed_profile="$profiles_dir/$profile_uuid.mobileprovision" + installed_xcode_profile="$xcode_profiles_dir/$profile_uuid.mobileprovision" + mkdir -p "$profiles_dir" "$xcode_profiles_dir" + cp "$profile_path" "$installed_profile" + cp "$profile_path" "$installed_xcode_profile" + + xcode_project="$GITHUB_WORKSPACE/apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj" + export_options="$GITHUB_WORKSPACE/apps/remote-ios/src-tauri/gen/apple/ExportOptions.plist" + signing_xcconfig="$RUNNER_TEMP/ios-signing.xcconfig" + cp "$xcode_project" "$RUNNER_TEMP/ios-project.pbxproj.backup" + cp "$export_options" "$RUNNER_TEMP/ios-export-options.plist.backup" + IOS_XCODE_PROJECT_FILE="$xcode_project" \ + IOS_EXPORT_OPTIONS_FILE="$export_options" \ + IOS_SIGNING_XCCONFIG="$signing_xcconfig" \ + IOS_SIGNING_IDENTITY_FILE="$RUNNER_TEMP/ios-signing-identity" \ + IOS_PROFILE_UUID_FILE="$RUNNER_TEMP/ios-profile-uuid" \ + node scripts/ios-release/manual-signing.mjs + plutil -lint "$export_options" + + { + echo "IOS_INSTALLED_PROFILE=$installed_profile" + echo "IOS_INSTALLED_XCODE_PROFILE=$installed_xcode_profile" + echo "IOS_XCODE_PROJECT_FILE=$xcode_project" + echo "IOS_EXPORT_OPTIONS_FILE=$export_options" + echo "XCODE_XCCONFIG_FILE=$signing_xcconfig" + } >> "$GITHUB_ENV" + + - name: Build signed release with bundled frontend + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail + security unlock-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$IOS_KEYCHAIN_PATH" pnpm tauri:ios:build --ci --export-method app-store-connect --config "$RUNNER_TEMP/ios-release-config.json" - name: Validate the exported IPA, not just source configuration @@ -146,3 +311,23 @@ jobs: xcrun altool --validate-app -f "$ipa" --type ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" xcrun altool --upload-app -f "$ipa" --type ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" echo 'Upload accepted. Processing, export-compliance questions, tester access and review are separate App Store Connect steps.' >> "$GITHUB_STEP_SUMMARY" + + - name: Remove iOS signing materials + if: always() + run: | + set +x + set +e + [[ -z "${IOS_INSTALLED_PROFILE:-}" ]] || rm -f "$IOS_INSTALLED_PROFILE" + [[ -z "${IOS_INSTALLED_XCODE_PROFILE:-}" ]] || rm -f "$IOS_INSTALLED_XCODE_PROFILE" + [[ -z "${IOS_KEYCHAIN_PATH:-}" ]] || security delete-keychain "$IOS_KEYCHAIN_PATH" + [[ -z "${IOS_XCODE_PROJECT_FILE:-}" || ! -f "$RUNNER_TEMP/ios-project.pbxproj.backup" ]] || cp "$RUNNER_TEMP/ios-project.pbxproj.backup" "$IOS_XCODE_PROJECT_FILE" + [[ -z "${IOS_EXPORT_OPTIONS_FILE:-}" || ! -f "$RUNNER_TEMP/ios-export-options.plist.backup" ]] || cp "$RUNNER_TEMP/ios-export-options.plist.backup" "$IOS_EXPORT_OPTIONS_FILE" + rm -f \ + "$RUNNER_TEMP/ios-distribution.p12" \ + "$RUNNER_TEMP/ios-app-store.mobileprovision" \ + "$RUNNER_TEMP/ios-app-store.plist" \ + "$RUNNER_TEMP/ios-profile-uuid" \ + "$RUNNER_TEMP/ios-signing-identity" \ + "$RUNNER_TEMP/ios-signing.xcconfig" \ + "$RUNNER_TEMP/ios-project.pbxproj.backup" \ + "$RUNNER_TEMP/ios-export-options.plist.backup" diff --git a/apps/remote-ios/src-tauri/.gitignore b/apps/remote-ios/src-tauri/.gitignore index 6993591f6b..a0736c032b 100644 --- a/apps/remote-ios/src-tauri/.gitignore +++ b/apps/remote-ios/src-tauri/.gitignore @@ -1,3 +1,4 @@ # Generated by Cargo and Tauri tooling /target/ /gen/schemas/ +/plugins/*/.tauri/ diff --git a/apps/remote-ios/src-tauri/Cargo.lock b/apps/remote-ios/src-tauri/Cargo.lock index e5951caeae..ff12cb4cc4 100644 --- a/apps/remote-ios/src-tauri/Cargo.lock +++ b/apps/remote-ios/src-tauri/Cargo.lock @@ -504,12 +504,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.2" @@ -609,23 +603,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -635,7 +612,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -699,6 +676,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "defmt" version = "1.1.1" @@ -739,19 +727,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -866,12 +841,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser", "foldhash", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.1", + "selectors", + "tendril", ] [[package]] @@ -1109,16 +1084,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -1203,15 +1168,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1321,17 +1277,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1340,7 +1285,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1556,18 +1501,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -1575,7 +1508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] @@ -1691,7 +1624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2031,18 +1964,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.1", - "selectors 0.24.0", -] - [[package]] name = "libappindicator" version = "0.9.0" @@ -2073,6 +1994,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2119,26 +2049,6 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -2146,27 +2056,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.1", + "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" version = "2.8.3" @@ -2215,15 +2108,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "muda" -version = "0.17.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2234,10 +2127,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2255,12 +2148,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2276,12 +2163,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "num-conv" version = "0.2.2" @@ -2342,6 +2223,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2366,6 +2268,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2423,8 +2357,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2496,6 +2449,7 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-opener", + "tauri-plugin-speech", ] [[package]] @@ -2558,104 +2512,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.8", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.8", + "phf_generator", + "phf_shared", ] [[package]] @@ -2665,21 +2540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", + "phf_shared", ] [[package]] @@ -2688,47 +2549,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.3", -] - [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.3", + "siphasher", ] [[package]] @@ -2780,6 +2614,19 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "polling" version = "3.11.0" @@ -2824,15 +2671,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2892,12 +2730,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.107" @@ -2937,87 +2769,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3260,24 +3011,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" @@ -3285,15 +3018,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.13.1", - "cssparser 0.36.0", - "derive_more 2.1.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -3457,16 +3190,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -3509,12 +3232,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "siphasher" version = "1.0.3" @@ -3597,19 +3314,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -3618,30 +3322,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -3670,7 +3362,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -3731,15 +3422,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.8" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", "core-foundation", "core-graphics", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -3750,13 +3442,14 @@ dependencies = [ "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -3786,9 +3479,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" dependencies = [ "anyhow", "bytes", @@ -3867,7 +3560,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -3956,11 +3649,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-speech" +version = "0.1.0" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -3983,9 +3686,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -4020,14 +3723,12 @@ dependencies = [ "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.13.1", + "phf", "plist", "proc-macro2", "quote", @@ -4071,17 +3772,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.1" @@ -4420,9 +4110,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -4434,10 +4124,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4547,12 +4237,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4622,12 +4306,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4727,10 +4405,10 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -5027,15 +4705,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5069,30 +4738,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -5123,12 +4775,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5141,12 +4787,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5159,24 +4799,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5189,12 +4817,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5207,12 +4829,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5225,12 +4841,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5243,12 +4853,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -5297,9 +4901,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", @@ -5453,26 +5057,6 @@ dependencies = [ "serde", ] -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/apps/remote-ios/src-tauri/Cargo.toml b/apps/remote-ios/src-tauri/Cargo.toml index 4a679467c1..e6be444f77 100644 --- a/apps/remote-ios/src-tauri/Cargo.toml +++ b/apps/remote-ios/src-tauri/Cargo.toml @@ -16,9 +16,10 @@ tauri-build = { version = "2.5.6", features = [] } [dependencies] serde_json = "1" -tauri = { version = "=2.10.3", features = [] } +tauri = { version = "=2.11.1", features = [] } tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" +tauri-plugin-speech = { path = "plugins/speech" } [target.'cfg(target_vendor = "apple")'.dependencies] security-framework = "3.7.0" diff --git a/apps/remote-ios/src-tauri/Info.ios.plist b/apps/remote-ios/src-tauri/Info.ios.plist index ea6a12bc16..ab9bed7222 100644 --- a/apps/remote-ios/src-tauri/Info.ios.plist +++ b/apps/remote-ios/src-tauri/Info.ios.plist @@ -4,6 +4,12 @@ NSCameraUsageDescription 扫描电脑上的配对二维码以连接 ORG2 Remote / Scan your desktop pairing QR code to connect ORG2 Remote + NSMicrophoneUsageDescription + 使用麦克风将语音转成消息文字 / Use the microphone to turn speech into message text + NSSpeechRecognitionUsageDescription + 将语音识别为消息文字 / Recognize speech as message text + ITSAppUsesNonExemptEncryption + CFBundleURLTypes diff --git a/apps/remote-ios/src-tauri/capabilities/mobile.json b/apps/remote-ios/src-tauri/capabilities/mobile.json index c4c7de9373..4f435718ec 100644 --- a/apps/remote-ios/src-tauri/capabilities/mobile.json +++ b/apps/remote-ios/src-tauri/capabilities/mobile.json @@ -8,6 +8,7 @@ "core:default", "core:event:default", "deep-link:default", + "speech:default", { "identifier": "opener:allow-open-url", "allow": [ diff --git a/apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj b/apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj index 04ed3de8b7..ad1f1a055f 100644 --- a/apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj +++ b/apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 652A1C1A2C4650C0DBB0D107 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA94EAB943C480C6D901740E /* WebKit.framework */; }; 848449A8A26D5ABCBB9D3D44 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 09999C01BEF32C9D12B9594C /* main.mm */; }; A9FBA82AAABB47D494C53BBC /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1386F1869ECC7C9F605BB80 /* UIKit.framework */; }; + E06BB14DC03A735D21DDB446 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5659F41309F2BBE4AB265FEA /* PrivacyInfo.xcprivacy */; }; FD65BF213E21D25393C10432 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09BEDDA01D3B6AF50AB4C557 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -26,6 +27,7 @@ 09999C01BEF32C9D12B9594C /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; 09BEDDA01D3B6AF50AB4C557 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4E8C456B445C726BEF9E9257 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; + 5659F41309F2BBE4AB265FEA /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 582265530F2F2E7B757D7C0E /* org2-remote_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "org2-remote_iOS.entitlements"; sourceTree = ""; }; 680653A546C7ECEDBC792278 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; 796E95FE899E78FC89082DC8 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; @@ -66,6 +68,7 @@ children = ( E49FDFC669F6670256C4A142 /* Info.plist */, 582265530F2F2E7B757D7C0E /* org2-remote_iOS.entitlements */, + 5659F41309F2BBE4AB265FEA /* PrivacyInfo.xcprivacy */, ); path = "org2-remote_iOS"; sourceTree = ""; @@ -210,6 +213,7 @@ files = ( FD65BF213E21D25393C10432 /* Assets.xcassets in Resources */, 52049DEC689E516DA7E7B1D6 /* LaunchScreen.storyboard in Resources */, + E06BB14DC03A735D21DDB446 /* PrivacyInfo.xcprivacy in Resources */, 0E9EE0CB965D103591BBD989 /* assets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/Info.plist b/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/Info.plist index 10884ad5c3..40a18dcf0f 100644 --- a/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/Info.plist +++ b/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/Info.plist @@ -2,8 +2,14 @@ - NSCameraUsageDescription - 扫描电脑上的配对二维码以连接 ORG2 Remote / Scan your desktop pairing QR code to connect ORG2 Remote + NSCameraUsageDescription + 扫描电脑上的配对二维码以连接 ORG2 Remote / Scan your desktop pairing QR code to connect ORG2 Remote + NSMicrophoneUsageDescription + 使用麦克风将语音转成消息文字 / Use the microphone to turn speech into message text + NSSpeechRecognitionUsageDescription + 将语音识别为消息文字 / Recognize speech as message text + ITSAppUsesNonExemptEncryption + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/PrivacyInfo.xcprivacy b/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..bfae8b7ba9 --- /dev/null +++ b/apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,106 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeName + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUserContent + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/apps/remote-ios/src-tauri/plugins/speech/Cargo.toml b/apps/remote-ios/src-tauri/plugins/speech/Cargo.toml new file mode 100644 index 0000000000..d4e235eda9 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tauri-plugin-speech" +version = "0.1.0" +edition = "2021" +rust-version = "1.85.0" +links = "tauri-plugin-speech" + +[lib] +name = "tauri_plugin_speech" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +tauri = { version = "=2.11.1", features = [] } +thiserror = "2" + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/apps/remote-ios/src-tauri/plugins/speech/build.rs b/apps/remote-ios/src-tauri/plugins/speech/build.rs new file mode 100644 index 0000000000..0889d08a3f --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/build.rs @@ -0,0 +1,5 @@ +const COMMANDS: &[&str] = &["is_supported", "start", "stop", "cancel"]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS).ios_path("ios").build(); +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/ios/Package.resolved b/apps/remote-ios/src-tauri/plugins/speech/ios/Package.resolved new file mode 100644 index 0000000000..e32718c299 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/ios/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Brendonovich/swift-rs", + "state" : { + "revision" : "f64a4514de07f450ec5b6aa297624cd3479d9579", + "version" : "1.0.7" + } + } + ], + "version" : 2 +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/ios/Package.swift b/apps/remote-ios/src-tauri/plugins/speech/ios/Package.swift new file mode 100644 index 0000000000..9207bce90a --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/ios/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "tauri-plugin-speech", + platforms: [.iOS(.v17)], + products: [ + .library( + name: "tauri-plugin-speech", + type: .static, + targets: ["tauri-plugin-speech"] + ) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + .target( + name: "tauri-plugin-speech", + dependencies: [.byName(name: "Tauri")], + path: "Sources" + ) + ] +) diff --git a/apps/remote-ios/src-tauri/plugins/speech/ios/Sources/SpeechPlugin.swift b/apps/remote-ios/src-tauri/plugins/speech/ios/Sources/SpeechPlugin.swift new file mode 100644 index 0000000000..b4156e32f8 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/ios/Sources/SpeechPlugin.swift @@ -0,0 +1,370 @@ +import AVFoundation +import Speech +import SwiftRs +import Tauri +import UIKit + +private struct StartArgs: Decodable { + let lang: String? + let sessionId: String +} + +private struct SessionArgs: Decodable { + let sessionId: String +} + +private struct SupportResponse: Encodable { + let supported: Bool +} + +private struct SpeechEvent: Encodable { + let sessionId: String + let kind: String + let transcript: String? + let code: String? + let message: String? +} + +final class SpeechPlugin: Plugin { + private let audioEngine = AVAudioEngine() + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var activeSessionId: String? + private var pendingSessionId: String? + private var latestTranscript = "" + private var finishing = false + private var tapInstalled = false + private var deadline: DispatchWorkItem? + private var backgroundObserver: NSObjectProtocol? + + override init() { + super.init() + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.pendingSessionId = nil + self?.finishSession(kind: "cancelled", cancelTask: true) + } + } + + deinit { + if let backgroundObserver { + NotificationCenter.default.removeObserver(backgroundObserver) + } + deadline?.cancel() + cleanupResources(cancelTask: true) + } + + @objc func isSupported(_ invoke: Invoke) { + invoke.resolve(SupportResponse(supported: SFSpeechRecognizer() != nil)) + } + + @objc func start(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(StartArgs.self) + DispatchQueue.main.async { [weak self] in + self?.requestPermissionsAndStart(args: args, invoke: invoke) + } + } + + @objc func stop(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(SessionArgs.self) + DispatchQueue.main.async { [weak self] in + guard let self, self.activeSessionId == args.sessionId else { + invoke.resolve() + return + } + self.finishing = true + self.stopAudioCapture() + self.recognitionRequest?.endAudio() + invoke.resolve() + } + } + + @objc func cancel(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(SessionArgs.self) + DispatchQueue.main.async { [weak self] in + guard let self else { + invoke.resolve() + return + } + if self.pendingSessionId == args.sessionId { + self.pendingSessionId = nil + invoke.resolve() + return + } + guard self.activeSessionId == args.sessionId else { + invoke.resolve() + return + } + self.finishSession(kind: "cancelled", cancelTask: true) + invoke.resolve() + } + } + + private func requestPermissionsAndStart(args: StartArgs, invoke: Invoke) { + guard activeSessionId == nil, pendingSessionId == nil else { + invoke.reject("Speech recognition is already active", code: "busy") + return + } + pendingSessionId = args.sessionId + + requestSpeechPermission { [weak self] speechGranted in + guard let self else { return } + guard self.pendingSessionId == args.sessionId else { + invoke.reject("Speech recognition was cancelled", code: "cancelled") + return + } + guard speechGranted else { + self.pendingSessionId = nil + invoke.reject("Speech recognition permission denied", code: "permission-denied") + return + } + self.requestMicrophonePermission { [weak self] microphoneGranted in + guard let self else { return } + guard self.pendingSessionId == args.sessionId else { + invoke.reject("Speech recognition was cancelled", code: "cancelled") + return + } + guard microphoneGranted else { + self.pendingSessionId = nil + invoke.reject("Microphone permission denied", code: "permission-denied") + return + } + self.pendingSessionId = nil + self.beginRecognition(args: args, invoke: invoke) + } + } + } + + private func requestSpeechPermission(completion: @escaping (Bool) -> Void) { + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: + completion(true) + case .notDetermined: + SFSpeechRecognizer.requestAuthorization { status in + DispatchQueue.main.async { completion(status == .authorized) } + } + case .denied, .restricted: + completion(false) + @unknown default: + completion(false) + } + } + + private func requestMicrophonePermission(completion: @escaping (Bool) -> Void) { + if #available(iOS 17.0, *) { + switch AVAudioApplication.shared.recordPermission { + case .granted: + completion(true) + case .undetermined: + AVAudioApplication.requestRecordPermission { granted in + DispatchQueue.main.async { completion(granted) } + } + case .denied: + completion(false) + @unknown default: + completion(false) + } + return + } + + switch AVAudioSession.sharedInstance().recordPermission { + case .granted: + completion(true) + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { completion(granted) } + } + case .denied: + completion(false) + @unknown default: + completion(false) + } + } + + private func beginRecognition(args: StartArgs, invoke: Invoke) { + let locale = args.lang.map(Locale.init(identifier:)) ?? Locale.current + guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { + invoke.reject("Speech recognition is unavailable", code: "unsupported") + return + } + + cleanupResources(cancelTask: true) + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + recognitionRequest = request + activeSessionId = args.sessionId + latestTranscript = "" + finishing = false + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else { + finishSession( + kind: "error", + code: "audio-capture", + message: "No microphone input is available", + cancelTask: true + ) + invoke.reject("No microphone input is available", code: "audio-capture") + return + } + + inputNode.installTap( + onBus: 0, + bufferSize: 1_024, + format: recordingFormat + ) { [weak request] buffer, _ in + request?.append(buffer) + } + tapInstalled = true + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: [.duckOthers]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + audioEngine.prepare() + try audioEngine.start() + } catch { + finishSession( + kind: "error", + code: "audio-capture", + message: error.localizedDescription, + cancelTask: true + ) + invoke.reject(error.localizedDescription, code: "audio-capture") + return + } + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + DispatchQueue.main.async { + self?.handleRecognitionResult( + result: result, + error: error, + sessionId: args.sessionId + ) + } + } + let deadline = DispatchWorkItem { [weak self] in + guard let self, self.activeSessionId == args.sessionId else { return } + self.finishing = true + self.stopAudioCapture() + self.recognitionRequest?.endAudio() + } + self.deadline = deadline + DispatchQueue.main.asyncAfter(deadline: .now() + 55, execute: deadline) + emit(kind: "started", sessionId: args.sessionId) + invoke.resolve() + } + + private func handleRecognitionResult( + result: SFSpeechRecognitionResult?, + error: Error?, + sessionId: String + ) { + guard activeSessionId == sessionId else { return } + + if let result { + latestTranscript = result.bestTranscription.formattedString + emit( + kind: result.isFinal ? "final" : "partial", + sessionId: sessionId, + transcript: latestTranscript + ) + if result.isFinal { + finishSession(kind: "ended", transcript: latestTranscript, cancelTask: false) + return + } + } + + if let error { + if finishing, !latestTranscript.isEmpty { + finishSession(kind: "ended", transcript: latestTranscript, cancelTask: false) + } else { + let nsError = error as NSError + let code = nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1101 + ? "network" + : (latestTranscript.isEmpty ? "no-speech" : "unknown") + finishSession( + kind: "error", + transcript: latestTranscript.isEmpty ? nil : latestTranscript, + code: code, + message: error.localizedDescription, + cancelTask: false + ) + } + } + } + + private func stopAudioCapture() { + if audioEngine.isRunning { + audioEngine.stop() + } + if tapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + } + + private func cleanupResources(cancelTask: Bool) { + deadline?.cancel() + deadline = nil + stopAudioCapture() + if cancelTask { + recognitionTask?.cancel() + } + recognitionTask = nil + recognitionRequest = nil + try? AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + } + + private func finishSession( + kind: String, + transcript: String? = nil, + code: String? = nil, + message: String? = nil, + cancelTask: Bool + ) { + guard let sessionId = activeSessionId else { return } + activeSessionId = nil + finishing = false + cleanupResources(cancelTask: cancelTask) + emit( + kind: kind, + sessionId: sessionId, + transcript: transcript, + code: code, + message: message + ) + latestTranscript = "" + } + + private func emit( + kind: String, + sessionId: String, + transcript: String? = nil, + code: String? = nil, + message: String? = nil + ) { + try? trigger( + "speech", + data: SpeechEvent( + sessionId: sessionId, + kind: kind, + transcript: transcript, + code: code, + message: message + ) + ) + } +} + +@_cdecl("init_plugin_speech") +func initPlugin() -> Plugin { + SpeechPlugin() +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/cancel.toml b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/cancel.toml new file mode 100644 index 0000000000..91efeaa00e --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/cancel.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cancel" +description = "Enables the cancel command without any pre-configured scope." +commands.allow = ["cancel"] + +[[permission]] +identifier = "deny-cancel" +description = "Denies the cancel command without any pre-configured scope." +commands.deny = ["cancel"] diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/is_supported.toml b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/is_supported.toml new file mode 100644 index 0000000000..caefef3e2b --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/is_supported.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-supported" +description = "Enables the is_supported command without any pre-configured scope." +commands.allow = ["is_supported"] + +[[permission]] +identifier = "deny-is-supported" +description = "Denies the is_supported command without any pre-configured scope." +commands.deny = ["is_supported"] diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/start.toml b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/start.toml new file mode 100644 index 0000000000..9af51d6097 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/start.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-start" +description = "Enables the start command without any pre-configured scope." +commands.allow = ["start"] + +[[permission]] +identifier = "deny-start" +description = "Denies the start command without any pre-configured scope." +commands.deny = ["start"] diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/stop.toml b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/stop.toml new file mode 100644 index 0000000000..6230cb91bb --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/commands/stop.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-stop" +description = "Enables the stop command without any pre-configured scope." +commands.allow = ["stop"] + +[[permission]] +identifier = "deny-stop" +description = "Denies the stop command without any pre-configured scope." +commands.deny = ["stop"] diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/reference.md b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/reference.md new file mode 100644 index 0000000000..54ce712aa4 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/autogenerated/reference.md @@ -0,0 +1,123 @@ +## Default Permission + +Allows ORG2 Remote to run iOS speech recognition + +#### This default permission set includes the following: + +- `allow-is-supported` +- `allow-start` +- `allow-stop` +- `allow-cancel` + +## Permission Table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IdentifierDescription
+ +`speech:allow-cancel` + + + +Enables the cancel command without any pre-configured scope. + +
+ +`speech:deny-cancel` + + + +Denies the cancel command without any pre-configured scope. + +
+ +`speech:allow-is-supported` + + + +Enables the is_supported command without any pre-configured scope. + +
+ +`speech:deny-is-supported` + + + +Denies the is_supported command without any pre-configured scope. + +
+ +`speech:allow-start` + + + +Enables the start command without any pre-configured scope. + +
+ +`speech:deny-start` + + + +Denies the start command without any pre-configured scope. + +
+ +`speech:allow-stop` + + + +Enables the stop command without any pre-configured scope. + +
+ +`speech:deny-stop` + + + +Denies the stop command without any pre-configured scope. + +
diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/default.toml b/apps/remote-ios/src-tauri/plugins/speech/permissions/default.toml new file mode 100644 index 0000000000..1eea1c3c9c --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/default.toml @@ -0,0 +1,8 @@ +[default] +description = "Allows ORG2 Remote to run iOS speech recognition" +permissions = [ + "allow-is-supported", + "allow-start", + "allow-stop", + "allow-cancel", +] diff --git a/apps/remote-ios/src-tauri/plugins/speech/permissions/schemas/schema.json b/apps/remote-ios/src-tauri/plugins/speech/permissions/schemas/schema.json new file mode 100644 index 0000000000..47bad24d7e --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/permissions/schemas/schema.json @@ -0,0 +1,315 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionFile", + "description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.", + "type": "object", + "properties": { + "default": { + "description": "The default permission set for the plugin", + "anyOf": [ + { + "$ref": "#/definitions/DefaultPermission" + }, + { + "type": "null" + } + ] + }, + "set": { + "description": "A list of permissions sets defined", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionSet" + } + }, + "permission": { + "description": "A list of inlined permissions", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/Permission" + } + } + }, + "definitions": { + "DefaultPermission": { + "description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.", + "type": "object", + "required": ["permissions"], + "properties": { + "version": { + "description": "The version of the permission.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 1.0 + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": ["string", "null"] + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionSet": { + "description": "A set of direct permissions grouped together under a new name.", + "type": "object", + "required": ["description", "identifier", "permissions"], + "properties": { + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does.", + "type": "string" + }, + "permissions": { + "description": "All permissions this set contains.", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionKind" + } + } + } + }, + "Permission": { + "description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.", + "type": "object", + "required": ["identifier"], + "properties": { + "version": { + "description": "The version of the permission.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 1.0 + }, + "identifier": { + "description": "A unique identifier for the permission.", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the permission does. Tauri internal convention is to use `

` headings in markdown content for Tauri documentation generation purposes.", + "type": ["string", "null"] + }, + "commands": { + "description": "Allowed or denied commands when using this permission.", + "default": { + "allow": [], + "deny": [] + }, + "allOf": [ + { + "$ref": "#/definitions/Commands" + } + ] + }, + "scope": { + "description": "Allowed or denied scoped when using this permission.", + "allOf": [ + { + "$ref": "#/definitions/Scopes" + } + ] + }, + "platforms": { + "description": "Target platforms this permission applies. By default all platforms are affected by this permission.", + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "Commands": { + "description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.", + "type": "object", + "properties": { + "allow": { + "description": "Allowed command.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "description": "Denied command, which takes priority.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Scopes": { + "description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```", + "type": "object", + "properties": { + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": ["array", "null"], + "items": { + "$ref": "#/definitions/Value" + } + } + } + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": ["macOS"] + }, + { + "description": "Windows.", + "type": "string", + "enum": ["windows"] + }, + { + "description": "Linux.", + "type": "string", + "enum": ["linux"] + }, + { + "description": "Android.", + "type": "string", + "enum": ["android"] + }, + { + "description": "iOS.", + "type": "string", + "enum": ["iOS"] + } + ] + }, + "PermissionKind": { + "type": "string", + "oneOf": [ + { + "description": "Enables the cancel command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel", + "markdownDescription": "Enables the cancel command without any pre-configured scope." + }, + { + "description": "Denies the cancel command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel", + "markdownDescription": "Denies the cancel command without any pre-configured scope." + }, + { + "description": "Enables the is_supported command without any pre-configured scope.", + "type": "string", + "const": "allow-is-supported", + "markdownDescription": "Enables the is_supported command without any pre-configured scope." + }, + { + "description": "Denies the is_supported command without any pre-configured scope.", + "type": "string", + "const": "deny-is-supported", + "markdownDescription": "Denies the is_supported command without any pre-configured scope." + }, + { + "description": "Enables the start command without any pre-configured scope.", + "type": "string", + "const": "allow-start", + "markdownDescription": "Enables the start command without any pre-configured scope." + }, + { + "description": "Denies the start command without any pre-configured scope.", + "type": "string", + "const": "deny-start", + "markdownDescription": "Denies the start command without any pre-configured scope." + }, + { + "description": "Enables the stop command without any pre-configured scope.", + "type": "string", + "const": "allow-stop", + "markdownDescription": "Enables the stop command without any pre-configured scope." + }, + { + "description": "Denies the stop command without any pre-configured scope.", + "type": "string", + "const": "deny-stop", + "markdownDescription": "Denies the stop command without any pre-configured scope." + }, + { + "description": "Allows ORG2 Remote to run iOS speech recognition\n#### This default permission set includes:\n\n- `allow-is-supported`\n- `allow-start`\n- `allow-stop`\n- `allow-cancel`", + "type": "string", + "const": "default", + "markdownDescription": "Allows ORG2 Remote to run iOS speech recognition\n#### This default permission set includes:\n\n- `allow-is-supported`\n- `allow-start`\n- `allow-stop`\n- `allow-cancel`" + } + ] + } + } +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/src/error.rs b/apps/remote-ios/src-tauri/plugins/speech/src/error.rs new file mode 100644 index 0000000000..9734cf5c66 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/src/error.rs @@ -0,0 +1,21 @@ +use serde::ser::Serializer; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[cfg(target_os = "ios")] + #[error(transparent)] + PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), +} + +impl serde::Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/src/lib.rs b/apps/remote-ios/src-tauri/plugins/speech/src/lib.rs new file mode 100644 index 0000000000..ad5e4fb0c2 --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/src/lib.rs @@ -0,0 +1,19 @@ +use tauri::{plugin::TauriPlugin, Runtime}; +#[cfg(target_os = "ios")] +use tauri::Manager; + +mod error; +#[cfg(target_os = "ios")] +mod mobile; + +pub use error::{Error, Result}; + +pub fn init() -> TauriPlugin { + tauri::plugin::Builder::new("speech") + .setup(|_app, _api| { + #[cfg(target_os = "ios")] + _app.manage(mobile::init(_app, _api)?); + Ok(()) + }) + .build() +} diff --git a/apps/remote-ios/src-tauri/plugins/speech/src/mobile.rs b/apps/remote-ios/src-tauri/plugins/speech/src/mobile.rs new file mode 100644 index 0000000000..50060deb3a --- /dev/null +++ b/apps/remote-ios/src-tauri/plugins/speech/src/mobile.rs @@ -0,0 +1,18 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PluginApi, PluginHandle}, + AppHandle, Runtime, +}; + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_speech); + +pub fn init( + _app: &AppHandle, + api: PluginApi, +) -> crate::Result> { + let handle = api.register_ios_plugin(init_plugin_speech)?; + Ok(Speech(handle)) +} + +pub struct Speech(#[allow(dead_code)] PluginHandle); diff --git a/apps/remote-ios/src-tauri/src/lib.rs b/apps/remote-ios/src-tauri/src/lib.rs index 522e28e609..5a7934326a 100644 --- a/apps/remote-ios/src-tauri/src/lib.rs +++ b/apps/remote-ios/src-tauri/src/lib.rs @@ -101,6 +101,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_speech::init()) .invoke_handler(tauri::generate_handler![ mobile_keychain_read, mobile_keychain_write, diff --git a/config/rspack.config.js b/config/rspack.config.js index 0d155f4f08..407aa7841d 100644 --- a/config/rspack.config.js +++ b/config/rspack.config.js @@ -363,6 +363,7 @@ module.exports = () => { new ReactRefreshPlugin({ overlay: false }), new rspack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("development"), + "process.env.ORGII_MOBILE_REMOTE_NATIVE": JSON.stringify("false"), "process.env.ORGII_DEV_EAGER_APP": JSON.stringify(String(eagerDevApp)), // Match webpack: browser startup must never read a runtime process global. "process.env.ORGII_E2E": JSON.stringify(isE2E ? "1" : "0"), diff --git a/config/webpack.config.js b/config/webpack.config.js index a29fc1fd76..21224d05eb 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -686,6 +686,9 @@ module.exports = (env, argv) => { }), new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify(argv.mode), + "process.env.ORGII_MOBILE_REMOTE_NATIVE": JSON.stringify( + String(isMobileRemoteNativeBuild) + ), // Inline-compared in src/index.tsx so webpack constant-folds the // `webpackMode: "eager"` App import away on platforms that don't need it. "process.env.ORGII_DEV_EAGER_APP": JSON.stringify(String(eagerDevApp)), diff --git a/docs/frontend-ui-audit-2026-09-16/MobileTestFlight.md b/docs/frontend-ui-audit-2026-09-16/MobileTestFlight.md new file mode 100644 index 0000000000..a145291d60 --- /dev/null +++ b/docs/frontend-ui-audit-2026-09-16/MobileTestFlight.md @@ -0,0 +1,32 @@ +# Mobile TestFlight UI audit + +Release-snapshot control review. TypeScript AST inspection covered the 118 changed production TS/TSX files; manually inspected the native-element results, shared Button props/presentation, session menu and tool-detail close control. This is a focused control audit, not a claim of full visual or accessibility acceptance across the feature set. Real-device VoiceOver, large text, dark theme and post-login screenshots remain unverified. + +| Line | Element | Verdict | Reason | Suggested change | +| ------------------------------------------------------------------------------------ | ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------- | ---------------- | +| `src/modules/MobileRemote/components/composer/MobileComposerAttachmentButton.tsx:39` | Native file input | keep with reason | Hidden browser file-picker boundary; a shared text input cannot replace file selection | None | +| `src/modules/MobileRemote/platform/writeClipboardText.ts:18` | Temporary native textarea | keep with reason | Synchronous legacy clipboard DOM boundary restores focus/selection and removes the node in finally | None | +| `src/modules/MobileRemote/components/SessionViewMenu.tsx:35` | Grouping dropdown | keep with reason | Reuses shared Dropdown and its item icon/spacing tokens; mobile CSS constrains viewport sizing | None | +| `src/modules/MobileRemote/components/SessionViewMenu.tsx:108` | Menu trigger | keep with reason | Shared Button with iconOnly, accessible name and expanded state; touch geometry uses mobile token | None | +| `src/modules/MobileRemote/components/transcript/MobileToolDetailModal.tsx:91` | Close action | keep with reason | Shared Button and Modal; named icon action uses a small glyph within a token-sized touch target | None | + +AST review found no raw JSX action buttons or clickable div/span substitutes in the changed production files. Native creation inspection found only the clipboard textarea boundary above. Role-bearing status/log/dialog containers are informational or structural, not action substitutes. + +Verdict totals: **0 fix**, **5 keep with reason**, **0 abstract**. + +## CI follow-up: provider ownership and lifecycle + +The provider was over the 700-line gate. Context, connection state, authenticated handshake, RPC notifications and session actions now have separate owners. The existing socket wait helpers are reused. Three former connection helper modules had no production callers (only references among themselves); they were removed rather than retaining a second obsolete connection implementation. + +Architecture review covered compilation, live call paths/dead helpers, names, responsibility boundaries, fallback/error branches, initialization and resolver parity (layers 1–7, 9–10). Layer 8 wire verification is limited to the existing transport contract tests: no payload fields changed in this CI follow-up, and no production relay session was exercised. The provider remains the generation/retry dispatcher; extracted hooks receive its same stable refs. RPC availability is now React state as well as the imperative transport ref, so read-state and inbox consumers update on disconnect. Authentication/connection ref mirrors update in layout effects; successful handshake still publishes connection identity synchronously before restoring the transcript. + +The reconnect factory only stores its callbacks and never invokes them during construction. Two narrowly documented React Compiler ref warnings at that factory call are false positives; event-time generation guards remain intact. No general lint rule or baseline was relaxed. Promise rejections from background teardown and observer callbacks now have final handlers; normal RPC errors retain their existing UI paths. + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| ------------------ | ------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Background work | keep | Reconnect controller owns one timer/flight; provider closes transport on hide/unmount | Extraction preserves teardown and visibility dispatch | Existing provider/reconnect tests; physical-device idle measurements not run | +| Memory | keep | Read-state batches 200, watched IDs 1200, paired-desktop inventory 20 | No additional cache or polling | Existing read-state/inbox tests | +| Scope/isolation | keep | Account/endpoint/desktop draft key, connection generation, selection intent | Preserve scoped draft recreation and stale-result guards | Provider pairing/revocation/recovery tests | +| Rendering/hot path | fix | RPC ref previously read directly for rendered consumers | Publish matching RPC state at client creation/release | Provider and read-state regression suite | + +Performance verdict: blocked for full-device acceptance: physical-device visible/hidden CPU/RSS and production relay transitions were not measured. Unit lifecycle coverage is not a claim of production performance improvement. UI control verdict remains 0 fix, 5 keep with reason, 0 abstract; callback wiring still uses shared Button controls. diff --git a/scripts/ios-release/manual-signing.mjs b/scripts/ios-release/manual-signing.mjs new file mode 100644 index 0000000000..2d4976e6ca --- /dev/null +++ b/scripts/ios-release/manual-signing.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { resolve } from "node:path"; + +export const iosBundleId = "org2ai.org2.remote"; + +function xmlEscape(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function validateInputs({ team, identity, profileUuid }) { + assert.match(team, /^[A-Z0-9]{10}$/, "Invalid Apple Team ID"); + assert.match( + profileUuid, + /^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$/, + "Invalid provisioning profile UUID", + ); + assert.ok( + /^(Apple Distribution|iPhone Distribution): /.test(identity), + "Expected an Apple Distribution identity", + ); + assert.ok(!/[\r\n\0]/.test(identity), "Invalid signing identity"); + assert.ok( + identity.endsWith(`(${team})`), + "Signing identity Team ID mismatch", + ); +} + +function setBuildSetting(block, key, value) { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const setting = new RegExp(`^(\\s*)${escapedKey} = .*;$`, "m"); + if (setting.test(block)) { + return block.replace(setting, `$1${key} = ${value};`); + } + return block.replace( + /^(\s*)CODE_SIGN_ENTITLEMENTS = .*;$/m, + `$&\n$1${key} = ${value};`, + ); +} + +export function patchProjectForManualSigning(source, inputs) { + validateInputs(inputs); + const { team, identity, profileUuid } = inputs; + const marker = /^\s*CODE_SIGN_ENTITLEMENTS = .*;$/gm; + const positions = [...source.matchAll(marker)].map((match) => match.index); + assert.equal( + positions.length, + 2, + "Expected exactly two iOS target build configurations", + ); + + let result = source; + for (const position of positions.reverse()) { + const start = result.lastIndexOf("buildSettings = {", position); + const blockEnd = result.slice(position).match(/\n\s*};/); + const end = blockEnd ? position + blockEnd.index : -1; + assert.ok(start >= 0 && end > position, "Invalid Xcode project structure"); + let block = result.slice(start, end); + block = setBuildSetting(block, "CODE_SIGN_STYLE", "Manual"); + block = setBuildSetting(block, "CODE_SIGN_IDENTITY", `\"${identity}\"`); + block = setBuildSetting(block, "DEVELOPMENT_TEAM", `\"${team}\"`); + block = setBuildSetting( + block, + "PROVISIONING_PROFILE_SPECIFIER", + `\"${profileUuid}\"`, + ); + result = result.slice(0, start) + block + result.slice(end); + } + return result; +} + +export function signingXcconfig(inputs) { + validateInputs(inputs); + return [ + "CODE_SIGN_STYLE = Manual", + `CODE_SIGN_IDENTITY = ${inputs.identity}`, + `CODE_SIGN_IDENTITY[sdk=iphoneos*] = ${inputs.identity}`, + `DEVELOPMENT_TEAM = ${inputs.team}`, + `PROVISIONING_PROFILE_SPECIFIER = ${inputs.profileUuid}`, + `PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*] = ${inputs.profileUuid}`, + "", + ].join("\n"); +} + +export function exportOptionsPlist(inputs) { + validateInputs(inputs); + const identity = xmlEscape(inputs.identity); + return ` + + + + methodapp-store-connect + signingStylemanual + signingCertificate${identity} + teamID${inputs.team} + manageAppVersionAndBuildNumber + provisioningProfiles + ${iosBundleId}${inputs.profileUuid} + + +`; +} + +export function prepareManualSigning({ + projectPath, + exportOptionsPath, + xcconfigPath, + ...inputs +}) { + const project = readFileSync(projectPath, "utf8"); + writeFileSync( + projectPath, + patchProjectForManualSigning(project, inputs), + "utf8", + ); + writeFileSync(exportOptionsPath, exportOptionsPlist(inputs), "utf8"); + writeFileSync(xcconfigPath, signingXcconfig(inputs), { mode: 0o600 }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + prepareManualSigning({ + projectPath: process.env.IOS_XCODE_PROJECT_FILE, + exportOptionsPath: process.env.IOS_EXPORT_OPTIONS_FILE, + xcconfigPath: process.env.IOS_SIGNING_XCCONFIG, + team: process.env.APPLE_TEAM_ID, + identity: readFileSync(process.env.IOS_SIGNING_IDENTITY_FILE, "utf8").trim(), + profileUuid: readFileSync(process.env.IOS_PROFILE_UUID_FILE, "utf8").trim(), + }); + } catch (error) { + console.error(`Failed to prepare explicit iOS signing: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/ios-release/manual-signing.test.mjs b/scripts/ios-release/manual-signing.test.mjs new file mode 100644 index 0000000000..b23b7c1d0e --- /dev/null +++ b/scripts/ios-release/manual-signing.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + exportOptionsPlist, + patchProjectForManualSigning, + signingXcconfig, +} from "./manual-signing.mjs"; + +const inputs = { + team: "ABCDEFGHIJ", + identity: "Apple Distribution: Example, Inc. (ABCDEFGHIJ)", + profileUuid: "12345678-1234-1234-1234-1234567890ab", +}; + +const project = ` +1 /* debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = app.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + }; + name = debug; +}; +2 /* release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = app.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + }; + name = release; +}; +`; + +test("patches both target configurations without emitting detached settings", () => { + const result = patchProjectForManualSigning(project, inputs); + assert.equal(result.match(/CODE_SIGN_STYLE = Manual;/g)?.length, 2); + assert.equal(result.match(/PROVISIONING_PROFILE_SPECIFIER =/g)?.length, 2); + assert.equal(result.match(/iPhone Developer/g), null); + for (const block of result.matchAll(/buildSettings = \{([\s\S]*?)\n\s*\};/g)) { + assert.match(block[1], /CODE_SIGN_STYLE = Manual;/); + assert.match(block[1], /PROVISIONING_PROFILE_SPECIFIER =/); + } +}); + +test("patches the checked-in generated Xcode project", () => { + const source = readFileSync( + new URL( + "../../apps/remote-ios/src-tauri/gen/apple/org2-remote.xcodeproj/project.pbxproj", + import.meta.url, + ), + "utf8", + ); + const result = patchProjectForManualSigning(source, inputs); + assert.equal(result.match(/CODE_SIGN_STYLE = Manual;/g)?.length, 2); + assert.equal(result.match(/PROVISIONING_PROFILE_SPECIFIER =/g)?.length, 2); +}); + +test("writes matching manual archive and export settings", () => { + const xcconfig = signingXcconfig(inputs); + const exportOptions = exportOptionsPlist(inputs); + assert.match(xcconfig, /CODE_SIGN_STYLE = Manual/); + assert.match(xcconfig, /Apple Distribution: Example, Inc\./); + assert.match(exportOptions, /signingStyle<\/key>manual<\/string>/); + assert.match(exportOptions, /org2ai\.org2\.remote<\/key>/); + assert.match(exportOptions, /12345678-1234-1234-1234-1234567890ab/); +}); + +test("rejects identities from another team", () => { + assert.throws(() => + signingXcconfig({ + ...inputs, + identity: "Apple Distribution: Example, Inc. (OTHERTEAM1)", + }), + ); +}); diff --git a/scripts/ios-release/readiness.mjs b/scripts/ios-release/readiness.mjs index 7b52922b63..8ba05724fa 100644 --- a/scripts/ios-release/readiness.mjs +++ b/scripts/ios-release/readiness.mjs @@ -48,6 +48,14 @@ export function validateDistribution( info.NSCameraUsageDescription?.trim(), "Camera purpose string missing", ); + assert.ok( + info.NSMicrophoneUsageDescription?.trim(), + "Microphone purpose string missing", + ); + assert.ok( + info.NSSpeechRecognitionUsageDescription?.trim(), + "Speech recognition purpose string missing", + ); assert.ok( info.CFBundleURLTypes?.some((t) => t.CFBundleURLSchemes?.includes("org2remote"), @@ -158,19 +166,54 @@ export function validateApp(app, expected) { "Compiled app icon/assets are missing", ); const privacy = parsePlist(readFileSync(join(app, "PrivacyInfo.xcprivacy"))); - for (const key of [ - "NSPrivacyCollectedDataTypes", - "NSPrivacyAccessedAPITypes", - "NSPrivacyTrackingDomains", - ]) { - assert.ok(Array.isArray(privacy[key]), `Privacy manifest missing ${key}`); - } + validatePrivacyManifest(privacy); + // Structural validation cannot prove that the declarations match the app's + // actual data retention, third-party SDKs, or required-reason API usage. +} + +export function validatePrivacyManifest(privacy) { assert.equal( typeof privacy.NSPrivacyTracking, "boolean", "Privacy tracking declaration missing", ); - // This structural check is not an audit of the accuracy of privacy declarations. + const optionalArrays = [ + "NSPrivacyCollectedDataTypes", + "NSPrivacyAccessedAPITypes", + "NSPrivacyTrackingDomains", + ]; + for (const key of optionalArrays) { + if (privacy[key] === undefined) continue; + assert.ok(Array.isArray(privacy[key]), `Privacy manifest ${key} must be an array`); + assert.ok(privacy[key].length > 0, `Privacy manifest must omit empty ${key}`); + } + for (const entry of privacy.NSPrivacyCollectedDataTypes ?? []) { + assert.equal(typeof entry.NSPrivacyCollectedDataType, "string"); + assert.equal(typeof entry.NSPrivacyCollectedDataTypeLinked, "boolean"); + assert.equal(typeof entry.NSPrivacyCollectedDataTypeTracking, "boolean"); + assert.ok(Array.isArray(entry.NSPrivacyCollectedDataTypePurposes)); + assert.ok(entry.NSPrivacyCollectedDataTypePurposes.length > 0); + assert.ok( + entry.NSPrivacyCollectedDataTypePurposes.every( + (purpose) => typeof purpose === "string" && purpose.length > 0, + ), + ); + } + for (const entry of privacy.NSPrivacyAccessedAPITypes ?? []) { + assert.equal(typeof entry.NSPrivacyAccessedAPIType, "string"); + assert.ok(Array.isArray(entry.NSPrivacyAccessedAPITypeReasons)); + assert.ok(entry.NSPrivacyAccessedAPITypeReasons.length > 0); + assert.ok( + entry.NSPrivacyAccessedAPITypeReasons.every( + (reason) => typeof reason === "string" && reason.length > 0, + ), + ); + } + assert.ok( + (privacy.NSPrivacyTrackingDomains ?? []).every( + (domain) => typeof domain === "string" && domain.length > 0, + ), + ); } if ( diff --git a/scripts/ios-release/readiness.test.mjs b/scripts/ios-release/readiness.test.mjs index a48ca8524f..ea72d6e6bc 100644 --- a/scripts/ios-release/readiness.test.mjs +++ b/scripts/ios-release/readiness.test.mjs @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { releaseConfig, validateDistribution, + validatePrivacyManifest, bundleId, parsePlist, } from "./readiness.mjs"; @@ -32,6 +34,8 @@ function fixture() { CFBundleShortVersionString: expected.version, CFBundleVersion: expected.build, NSCameraUsageDescription: "Scan pairing codes", + NSMicrophoneUsageDescription: "Record dictation", + NSSpeechRecognitionUsageDescription: "Transcribe dictation", CFBundleURLTypes: [{ CFBundleURLSchemes: ["org2remote"] }], ITSAppUsesNonExemptEncryption: false, }; @@ -61,6 +65,70 @@ test("accepts matching distribution metadata, including legacy App ID prefixes", const f = fixture(); validateDistribution(f.info, f.profile, f.entitlements, f.expected); }); + +test("accepts a minimal privacy manifest without invalid empty declarations", () => { + validatePrivacyManifest({ NSPrivacyTracking: false }); +}); + +test("rejects empty optional privacy declarations", () => { + for (const key of [ + "NSPrivacyCollectedDataTypes", + "NSPrivacyAccessedAPITypes", + "NSPrivacyTrackingDomains", + ]) { + assert.throws(() => + validatePrivacyManifest({ NSPrivacyTracking: false, [key]: [] }), + ); + } +}); + +test("requires reasons for declared required-reason APIs", () => { + assert.throws(() => + validatePrivacyManifest({ + NSPrivacyTracking: false, + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp", + NSPrivacyAccessedAPITypeReasons: [], + }, + ], + }), + ); +}); + +test("ships the reviewed iOS privacy declarations", () => { + const privacy = parsePlist( + readFileSync( + new URL( + "../../apps/remote-ios/src-tauri/gen/apple/org2-remote_iOS/PrivacyInfo.xcprivacy", + import.meta.url, + ), + ), + ); + validatePrivacyManifest(privacy); + assert.equal(privacy.NSPrivacyTracking, false); + assert.deepEqual( + privacy.NSPrivacyCollectedDataTypes.map( + (entry) => entry.NSPrivacyCollectedDataType, + ), + [ + "NSPrivacyCollectedDataTypeName", + "NSPrivacyCollectedDataTypeEmailAddress", + "NSPrivacyCollectedDataTypeUserID", + "NSPrivacyCollectedDataTypeDeviceID", + "NSPrivacyCollectedDataTypeProductInteraction", + "NSPrivacyCollectedDataTypeOtherUserContent", + "NSPrivacyCollectedDataTypePhotosorVideos", + ], + ); + assert.deepEqual(privacy.NSPrivacyAccessedAPITypes, [ + { + NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryFileTimestamp", + NSPrivacyAccessedAPITypeReasons: ["C617.1"], + }, + ]); + assert.equal(privacy.NSPrivacyTrackingDomains, undefined); +}); for (const [name, mutate] of [ [ "debug app", @@ -116,6 +184,18 @@ for (const [name, mutate] of [ delete f.info.NSCameraUsageDescription; }, ], + [ + "missing microphone permission", + (f) => { + delete f.info.NSMicrophoneUsageDescription; + }, + ], + [ + "missing speech recognition permission", + (f) => { + delete f.info.NSSpeechRecognitionUsageDescription; + }, + ], [ "missing callback", (f) => { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5788810136..2174487085 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3440,10 +3440,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -5024,6 +5037,7 @@ dependencies = [ "futures-util", "git", "git_api", + "image", "inbox", "insta", "integrations", @@ -6634,9 +6648,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c49a6aca01..072ddbd739 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -431,6 +431,7 @@ dirs = "6.0" # Get user directories (~/.orgii/data) chrono = { workspace = true } # Date/time handling zip = { workspace = true } base64 = { workspace = true } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } # AWS SDK for SSO OIDC archived alongside `crates/cursor-bridge` notes. # `aws-config` + `aws-sdk-ssooidc` (and the transitive `aws-lc-sys` BoringSSL diff --git a/src-tauri/crates/agent-core/src/core/interaction/permission.rs b/src-tauri/crates/agent-core/src/core/interaction/permission.rs index d43977f5e8..6cd3ecc23c 100644 --- a/src-tauri/crates/agent-core/src/core/interaction/permission.rs +++ b/src-tauri/crates/agent-core/src/core/interaction/permission.rs @@ -66,6 +66,26 @@ struct PendingPermission { session_id: String, tool_name: String, tool_call_id: String, + tool_args: serde_json::Value, + created_at_ms: i64, +} + +struct PendingPermissionGuard { + pending: Arc>>, + request_id: String, +} + +impl Drop for PendingPermissionGuard { + fn drop(&mut self) { + // Cleanup must not panic again while a producer is unwinding. + let entry = match self.pending.lock() { + Ok(mut pending) => pending.remove(&self.request_id), + Err(poisoned) => poisoned.into_inner().remove(&self.request_id), + }; + if let Some(entry) = entry { + AgentPermissionManager::broadcast_pending_changed(&entry.session_id); + } + } } /// Manages pending permission requests for any agent session. @@ -74,7 +94,7 @@ struct PendingPermission { /// `broadcast_event` (routed to the frontend over the Tauri IPC Channel). pub struct AgentPermissionManager { /// Pending requests keyed by `request_id`. - pending: Arc>>, + pending: Arc>>, /// Session-scoped always-allow rules (tool name or tool+pattern). session_rules: Arc>>, /// Persistent rules loaded from `.orgii/permissions.json`. @@ -112,7 +132,7 @@ impl AgentPermissionManager { .take(8) .collect::(); Self { - pending: Arc::new(Mutex::new(HashMap::new())), + pending: Arc::new(std::sync::Mutex::new(HashMap::new())), session_rules: Arc::new(Mutex::new(Vec::new())), persistent_store: Arc::new(Mutex::new(PermissionStore::default())), workspace: Arc::new(Mutex::new(None)), @@ -144,16 +164,34 @@ impl AgentPermissionManager { session_id: &str, tool_name: &str, tool_call_id: &str, + ) -> (String, oneshot::Receiver) { + self.register_with_args( + session_id, + tool_name, + tool_call_id, + &serde_json::Value::Null, + ) + .await + } + + async fn register_with_args( + &self, + session_id: &str, + tool_name: &str, + tool_call_id: &str, + tool_args: &serde_json::Value, ) -> (String, oneshot::Receiver) { let request_id = format!("{}-{}-{}", self.id_prefix, session_id, uuid::Uuid::new_v4()); let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert( + self.pending.lock().expect("permission registry").insert( request_id.clone(), PendingPermission { sender: tx, session_id: session_id.to_string(), tool_name: tool_name.to_string(), tool_call_id: tool_call_id.to_string(), + tool_args: tool_args.clone(), + created_at_ms: chrono::Utc::now().timestamp_millis(), }, ); (request_id, rx) @@ -168,36 +206,65 @@ impl AgentPermissionManager { &self, request_id: &str, response: PermissionResponse, - tool_name: Option<&str>, - tool_args: Option<&serde_json::Value>, + _tool_name: Option<&str>, + _tool_args: Option<&serde_json::Value>, ) -> bool { + self.respond_scoped(None, request_id, response).await + } + + /// Remote callers must prove both request and owning session identity. + pub async fn respond_for_session( + &self, + session_id: &str, + request_id: &str, + response: PermissionResponse, + ) -> bool { + self.respond_scoped(Some(session_id), request_id, response) + .await + } + + async fn respond_scoped( + &self, + session_id: Option<&str>, + request_id: &str, + response: PermissionResponse, + ) -> bool { + // Claim before any side effect; stale or duplicate replies cannot grant rules. + let entry = { + let mut pending = self.pending.lock().expect("permission registry"); + if pending + .get(request_id) + .is_some_and(|entry| session_id.is_none_or(|id| entry.session_id == id)) + { + pending.remove(request_id) + } else { + None + } + }; + let Some(entry) = entry else { + return false; + }; + Self::broadcast_pending_changed(&entry.session_id); if response == PermissionResponse::AlwaysAllow { - if let Some(name) = tool_name { - let rule = Self::build_rule(name, tool_args); - info!("[permission] Always-allow granted: {}", rule.rule); - - // Add to session rules - let mut session = self.session_rules.lock().await; - if !session.contains(&rule) { - session.push(rule.clone()); - } + let rule = Self::build_rule(&entry.tool_name, Some(&entry.tool_args)); + info!("[permission] Always-allow granted: {}", rule.rule); - // Persist to .orgii/permissions.json - let mut store = self.persistent_store.lock().await; - store.add_allow(rule); - if let Some(ref ws) = *self.workspace.lock().await { - if let Err(err) = store.save(ws) { - warn!("[permission] Failed to persist rule: {}", err); - } + // Add to session rules + let mut session = self.session_rules.lock().await; + if !session.contains(&rule) { + session.push(rule.clone()); + } + + // Persist to .orgii/permissions.json + let mut store = self.persistent_store.lock().await; + store.add_allow(rule); + if let Some(ref ws) = *self.workspace.lock().await { + if let Err(err) = store.save(ws) { + warn!("[permission] Failed to persist rule: {}", err); } } } - let Some(entry) = self.pending.lock().await.remove(request_id) else { - warn!("[permission] No pending request found for {}", request_id); - return false; - }; - let (status, content) = match response { PermissionResponse::Allow => ( FinalizedStatus::Answered, @@ -246,9 +313,15 @@ impl AgentPermissionManager { /// adding a new `FinalizedStatus` variant forces a compiler error here /// rather than silently mapping it to a generic "terminated" string. async fn cancel_pending(&self, request_id: &str, status: FinalizedStatus) { - let Some(entry) = self.pending.lock().await.remove(request_id) else { + let Some(entry) = self + .pending + .lock() + .expect("permission registry") + .remove(request_id) + else { return; }; + Self::broadcast_pending_changed(&entry.session_id); let content = match status { FinalizedStatus::Cancelled => "User stopped the session before responding.", @@ -276,12 +349,41 @@ impl AgentPermissionManager { /// Check if there are any pending permission requests. pub async fn has_pending(&self) -> bool { - !self.pending.lock().await.is_empty() + !self.pending.lock().expect("permission registry").is_empty() } /// Get the IDs of all pending permission requests. pub async fn pending_ids(&self) -> Vec { - self.pending.lock().await.keys().cloned().collect() + self.pending + .lock() + .expect("permission registry") + .keys() + .cloned() + .collect() + } + + /// Recoverable projection of the actual parked requests, not an event cache. + pub async fn pending_snapshot(&self, limit: usize) -> Vec { + self.pending + .lock() + .expect("permission registry") + .iter() + .take(limit) + .map(|(id, entry)| { + serde_json::json!({ + "kind": "permission", "origin": "rust_agent", "sessionId": entry.session_id, + "requestId": id, "toolName": entry.tool_name, "toolCallId": entry.tool_call_id, + "toolArgs": entry.tool_args, "createdAtMs": entry.created_at_ms, + }) + }) + .collect() + } + + fn broadcast_pending_changed(session_id: &str) { + crate::bus::broadcast_event( + "permission:pending_changed", + serde_json::json!({"sessionId": session_id}), + ); } /// Build a permission rule from tool name + args. @@ -364,7 +466,13 @@ impl PermissionProvider for AgentPermissionManager { return Ok(PermissionVerdict::AlwaysAllow); } - let (request_id, rx) = self.register(session_id, tool_name, tool_call_id).await; + let (request_id, rx) = self + .register_with_args(session_id, tool_name, tool_call_id, args) + .await; + let _pending_guard = PendingPermissionGuard { + pending: Arc::clone(&self.pending), + request_id: request_id.clone(), + }; crate::bus::broadcast_event( "permission:request", @@ -430,9 +538,140 @@ impl PermissionProvider for AgentPermissionManager { InteractionOutcome::Dropped => { // Sender was dropped without cancel_pending (shouldn't happen in // the normal flow, but be defensive). - self.pending.lock().await.remove(&request_id); + self.pending + .lock() + .expect("permission registry") + .remove(&request_id); Err(()) } } } } + +#[cfg(test)] +mod pending_snapshot_tests { + use super::*; + + #[tokio::test] + async fn cleanup_does_not_panic_when_registry_was_poisoned() { + let manager = AgentPermissionManager::for_agent("poison-test"); + let (request_id, rx) = manager.register("session-a", "read_file", "call-a").await; + let guard = PendingPermissionGuard { + pending: manager.pending.clone(), + request_id, + }; + let pending = manager.pending.clone(); + assert!(std::thread::spawn(move || { + let _lock = pending.lock().unwrap(); + panic!("poison fixture"); + }) + .join() + .is_err()); + drop(guard); + assert!(rx.await.is_err()); + let pending = manager + .pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert!(pending.is_empty()); + } + + #[tokio::test] + async fn aborted_permission_future_releases_its_authoritative_entry() { + let manager = Arc::new(AgentPermissionManager::for_agent("abort-test")); + let task = tokio::spawn({ + let manager = manager.clone(); + async move { + manager + .request_permission( + "session-a", + "read_file", + "call-a", + &serde_json::json!({"path":"a.rs"}), + ) + .await + } + }); + for _ in 0..100 { + if manager.has_pending().await { + break; + } + tokio::task::yield_now().await; + } + assert!(manager.has_pending().await); + task.abort(); + let _ = task.await; + assert!(manager.pending_snapshot(10).await.is_empty()); + } + + #[tokio::test] + async fn snapshot_recovers_arguments_and_claim_prevents_duplicate_rules() { + let manager = AgentPermissionManager::for_agent("snapshot-test"); + let args = serde_json::json!({"command": "git status"}); + let (id, rx) = manager + .register_with_args("session-a", "run_shell", "call-a", &args) + .await; + let snapshot = manager.pending_snapshot(10).await; + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0]["toolArgs"], args); + assert_eq!(snapshot[0]["toolCallId"], "call-a"); + assert!(snapshot[0]["createdAtMs"].as_i64().unwrap() > 0); + assert!( + !manager + .respond( + "stale", + PermissionResponse::AlwaysAllow, + Some("foreign_tool"), + None + ) + .await + ); + assert!(manager.session_rules.lock().await.is_empty()); + assert!( + !manager + .respond_for_session("foreign", &id, PermissionResponse::AlwaysAllow) + .await + ); + assert_eq!(manager.pending_snapshot(10).await.len(), 1); + assert!( + manager + .respond( + &id, + PermissionResponse::AlwaysAllow, + Some("foreign_tool"), + None + ) + .await + ); + assert_eq!(rx.await.unwrap(), PermissionResponse::AlwaysAllow); + assert!( + !manager + .respond( + &id, + PermissionResponse::AlwaysAllow, + Some("foreign_tool"), + None + ) + .await + ); + let rules = manager.session_rules.lock().await; + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].rule, "run_shell(git *)"); + assert!(manager.pending_snapshot(10).await.is_empty()); + } + + #[tokio::test] + async fn another_session_manager_cannot_claim_foreign_request() { + let owner = AgentPermissionManager::for_agent("owner"); + let other = AgentPermissionManager::for_agent("other"); + let (id, _rx) = owner.register("session-a", "read_file", "call-a").await; + assert!( + !other + .respond(&id, PermissionResponse::Allow, None, None) + .await + ); + assert_eq!(owner.pending_snapshot(1).await.len(), 1); + owner.cancel_pending(&id, FinalizedStatus::Cancelled).await; + assert!(owner.pending_snapshot(1).await.is_empty()); + } +} diff --git a/src-tauri/crates/agent-core/src/core/tools/file_history/mod.rs b/src-tauri/crates/agent-core/src/core/tools/file_history/mod.rs index 9caf59c331..104ec0dcbe 100644 --- a/src-tauri/crates/agent-core/src/core/tools/file_history/mod.rs +++ b/src-tauri/crates/agent-core/src/core/tools/file_history/mod.rs @@ -50,6 +50,7 @@ mod capture; mod cleanup; mod inspect; mod paths; +mod review; mod rewind; mod types; @@ -63,6 +64,7 @@ pub use cleanup::{ }; pub use inspect::{has_changes_after_message, session_numstat, session_unified_diff}; pub use paths::read_snapshot_created_at as get_snapshot_created_at; +pub use review::read_pre_tool_file; pub use rewind::{ restore_snapshot, rewind_file, rewind_file_to_message, rewind_to_message, REDO_SNAPSHOT_TOOL_CALL_ID, diff --git a/src-tauri/crates/agent-core/src/core/tools/file_history/review.rs b/src-tauri/crates/agent-core/src/core/tools/file_history/review.rs new file mode 100644 index 0000000000..e26e7d6b2a --- /dev/null +++ b/src-tauri/crates/agent-core/src/core/tools/file_history/review.rs @@ -0,0 +1,109 @@ +//! Bounded historical read for review surfaces. Never consult the live file. +use super::{paths, types::FileSnapshot}; +use rusqlite::OptionalExtension; +use std::{ + fs::File, + io::{self, Read}, + path::{Component, Path}, +}; + +const MAX_BYTES: u64 = 512 * 1024; + +fn safe_segment(value: &str) -> bool { + !value.is_empty() + && Path::new(value).components().count() == 1 + && matches!( + Path::new(value).components().next(), + Some(Component::Normal(_)) + ) +} + +fn bounded_bytes(path: &Path) -> io::Result> { + if !std::fs::symlink_metadata(path)?.is_file() { + return Err(io::Error::other("not a regular backup")); + } + let file = File::open(path)?; + if !file.metadata()?.is_file() { + return Err(io::Error::other("not a regular backup")); + } + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BYTES { + return Err(io::Error::other("snapshot exceeds review budget")); + } + Ok(bytes) +} + +/// Return the pre-tool whole file only when the exact owner/tool/path backup +/// exists and its content hash verifies. Missing/evicted/binary backups stay +/// unavailable. An empty string means a captured non-existent file, not error. +pub fn read_pre_tool_file(session_id: &str, tool_call_id: &str, file_path: &str) -> Option { + if !safe_segment(session_id) { + return None; + } + let conn = database::db::get_connection().ok()?; + let id: Option = conn.query_row( + "SELECT hash FROM agent_snapshots WHERE session_id = ?1 AND tool_call_id = ?2 ORDER BY created_at ASC LIMIT 1", + rusqlite::params![session_id, tool_call_id], |row| row.get(0) + ).optional().ok()?; + read_file(session_id, &id?, file_path).ok() +} + +fn read_file(session_id: &str, id: &str, file_path: &str) -> io::Result { + if !safe_segment(session_id) || !safe_segment(id) { + return Err(io::Error::other("invalid snapshot identity")); + } + let snapshot: FileSnapshot = + serde_json::from_slice(&bounded_bytes(&paths::snapshot_file(session_id, id))?) + .map_err(io::Error::other)?; + let backup = snapshot + .backups + .get(file_path) + .ok_or_else(|| io::Error::other("file not captured"))?; + if backup.untrackable { + return Err(io::Error::other("untrackable file")); + } + let Some(hash) = &backup.content_hash else { + return Ok(String::new()); + }; + if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(io::Error::other("invalid backup hash")); + } + let bytes = bounded_bytes(&paths::backup_file(session_id, hash))?; + if bytes.contains(&0) || paths::hash_bytes(&bytes) != *hash { + return Err(io::Error::other("unavailable backup")); + } + String::from_utf8(bytes).map_err(io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn historical_reader_uses_capture_not_current_file_and_rejects_corruption() { + let sandbox = test_helpers::test_env::sandbox(); + let path = sandbox.path().join("review.txt"); + std::fs::write(&path, "before\n").unwrap(); + let id = super::super::make_tool_snapshot("review-owner", std::slice::from_ref(&path)).unwrap(); + let conn = database::db::get_connection().unwrap(); + crate::persistence::session_snapshots::ensure_tables_with(&conn).unwrap(); + crate::core::session::persistence::save_snapshot("review-owner", "edit-call", &id).unwrap(); + std::fs::write(&path, "unrelated later change\n").unwrap(); + assert_eq!( + read_file("review-owner", &id, path.to_str().unwrap()).unwrap(), + "before\n" + ); + assert_eq!( + read_pre_tool_file("review-owner", "edit-call", path.to_str().unwrap()).as_deref(), + Some("before\n") + ); + assert!( + read_pre_tool_file("review-owner", "unrelated-call", path.to_str().unwrap()).is_none() + ); + assert!(read_file("other-owner", &id, path.to_str().unwrap()).is_err()); + assert!(read_file("../outside", &id, path.to_str().unwrap()).is_err()); + let hash = paths::hash_bytes(b"before\n"); + std::fs::write(paths::backup_file("review-owner", &hash), "corrupt").unwrap(); + assert!(read_file("review-owner", &id, path.to_str().unwrap()).is_err()); + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/desktop_exec.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/desktop_exec.rs index ad4daec1e3..aeb0d739a9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/desktop_exec.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/desktop_exec.rs @@ -150,6 +150,16 @@ fn tool_args(script: &str, name: &str, expression: &str) -> Value { } } +/// Share the same non-evaluating scanner with metadata ingestion. Return each +/// patch once, before normalization splits a multi-file patch into tool rows. +pub(super) fn exec_patches(input: &str) -> Vec { + tool_invocations(input) + .into_iter() + .filter(|(name, _)| normalize_tool_name_key(name) == "apply_patch") + .filter_map(|(_, expression)| resolve_string_expression(input, expression)) + .collect() +} + /// Scan source-order calls while ignoring tool-looking text inside strings. fn tool_invocations(script: &str) -> Vec<(&str, &str)> { let bytes = script.as_bytes(); diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/impact.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/impact.rs index 6221ce4184..3a24a13b19 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/impact.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/impact.rs @@ -134,7 +134,7 @@ fn patch_from_codex_args(args: &Value) -> Option { .map(str::to_string) } -fn accumulate_patch_impact( +pub(super) fn accumulate_patch_impact( patch: &str, impact: &mut ImportedHistoryImpactStats, touched_files: &mut BTreeSet, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs index 19f54f5661..1c4264672b 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs @@ -94,6 +94,10 @@ pub fn load_codex_app_for_session( Ok(chunks) } +pub fn codex_app_review_path(conn: &Connection, session_id: &str) -> Result { + resolve_codex_session_path(conn, codex_file_stem_from_session_id(session_id)?) +} + pub fn load_codex_app_initial_window_for_session( conn: &Connection, session_id: &str, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/meta.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/meta.rs index d7f6e07030..5bb28fedb9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/meta.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/meta.rs @@ -88,6 +88,8 @@ struct CodexSessionMetaState { touched_files: BTreeSet, fallback_impact: ImportedHistoryImpactStats, fallback_touched: BTreeSet, + #[serde(default)] + pending_exec_patches: std::collections::BTreeMap>, parent_thread_id: Option, source_metadata: CodexAppSourceMetadata, } @@ -226,6 +228,55 @@ impl CodexSessionMetaState { &mut self.fallback_impact, &mut self.fallback_touched, ); + // A wrapper is an intent, not proof of an edit. Attribute it only when + // its matching result confirms completion, including across append scans. + let payload = &parsed.payload; + if payload.get("type").and_then(Value::as_str) == Some("custom_tool_call") + && payload.get("name").and_then(Value::as_str) == Some("exec") + { + if let (Some(id), Some(input)) = ( + payload.get("call_id").and_then(Value::as_str), + payload.get("input").and_then(Value::as_str), + ) { + let patches = super::desktop_exec::exec_patches(input); + let retained: usize = self + .pending_exec_patches + .values() + .flatten() + .map(String::len) + .sum(); + if !patches.is_empty() + && self.pending_exec_patches.len() < 64 + && retained + patches.iter().map(String::len).sum::() <= 2 * 1024 * 1024 + { + self.pending_exec_patches.insert(id.to_string(), patches); + } + } + } else if payload.get("type").and_then(Value::as_str) == Some("custom_tool_call_output") { + if let Some(patches) = payload + .get("call_id") + .and_then(Value::as_str) + .and_then(|id| self.pending_exec_patches.remove(id)) + { + let output = super::desktop_exec::codex_tool_output_text(payload.get("output")); + if output.starts_with("Script completed") + && !super::desktop_exec::codex_tool_output_failed( + &output, + super::desktop_exec::codex_tool_exit_code(&output), + ) + && !output.contains("\"isError\":true") + && !output.contains("\"isError\": true") + { + for patch in patches { + super::impact::accumulate_patch_impact( + &patch, + &mut self.fallback_impact, + &mut self.fallback_touched, + ); + } + } + } + } } fn finish( diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/mod.rs index a48d109379..096c7e2205 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/mod.rs @@ -25,7 +25,7 @@ pub use index::load_codex_context_usage_for_session; // Public API — preserved at `...::sources::codex::app::*`. pub use index::{ - codex_thread_id_from_file_stem, list_codex_app_recent_paths, + codex_app_review_path, codex_thread_id_from_file_stem, list_codex_app_recent_paths, list_codex_app_reconciliation_sessions, list_codex_app_sessions_paginated, load_codex_app_cloud_turn_for_session, load_codex_app_for_session, load_codex_app_initial_window_for_session, load_codex_app_mobile_tail_window_for_session, @@ -35,11 +35,11 @@ pub use index::{ pub use meta::{resolve_codex_transcript_for_thread_id_near_path, CodexTranscriptLocator}; pub(crate) use normalize::normalize_codex_tool_calls; pub use transcript::{ - load_codex_image_from_path, load_codex_app_from_path, load_codex_app_initial_window_from_path, - load_codex_app_mobile_tail_window_from_path, load_codex_app_turn_from_path, - load_codex_app_window_turn_from_path, visit_codex_app_from_path, CodexAppInitialWindow, - CodexAppTurnWindow, + load_codex_app_mobile_tail_window_from_path, load_codex_app_review_context_from_path, + load_codex_app_review_from_path, load_codex_app_turn_from_path, + load_codex_app_window_turn_from_path, load_codex_image_from_path, visit_codex_app_from_path, + CodexAppInitialWindow, CodexAppTurnWindow, }; // Internal re-exports so the sibling `app_tests.rs` (`use super::*`) resolves. @@ -74,7 +74,8 @@ pub(crate) use transcript::{ // as no workspace. // v15: count completed `item_completed`/`FileChange` items. Codex Desktop // never persists `patch_apply_end`, so every `exec`-wrapped edit tallied zero. -const CODEX_APP_METADATA_PARSER_VERSION: i64 = 15; +// v16: retain empty successful results so mixed exec batches preserve edit status/order. +const CODEX_APP_METADATA_PARSER_VERSION: i64 = 16; pub type CodexAppSessionRow = ImportedHistorySessionRow; pub type CodexAppSessionPage = ImportedHistorySessionPage; diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs index bf087ebfbb..888e4f095e 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs @@ -14,6 +14,8 @@ const NATIVE_SOURCE_EVENT_ID_ARG: &str = "__orgiiSourceEventId"; const NATIVE_SOURCE_EVENT_ID_PREFIX: &str = "orgii_evt_"; pub use reader::{ + load_codex_app_review_from_path, + load_codex_app_review_context_from_path, load_codex_app_from_path, load_codex_app_initial_window_from_path, load_codex_app_mobile_tail_window_from_path, load_codex_app_turn_from_path, load_codex_app_window_turn_from_path, visit_codex_app_from_path, CodexAppInitialWindow, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/collector.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/collector.rs index 870f089a32..0ba2d12b77 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/collector.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/collector.rs @@ -22,6 +22,7 @@ pub(super) enum CodexTranscriptCollectionMode<'a> { Visit(&'a mut dyn FnMut(Vec) -> Result<(), String>), Initial { recent_turn_count: usize }, Turn { turn_id: &'a str }, + ReviewThroughCall { call_id: &'a str }, FirstTurn, } @@ -100,6 +101,13 @@ impl<'a> CodexTranscriptCollector<'a> { }; let turn_id = user_chunk.chunk_id.clone(); match &self.mode { + CodexTranscriptCollectionMode::ReviewThroughCall { call_id } => { + self.selected_turn_found = self.current.iter().any(|chunk| { + chunk.result.get("call_id").and_then(Value::as_str) == Some(*call_id) + }); + self.output.append(&mut self.current); + return Ok(()); + } CodexTranscriptCollectionMode::Full => { self.output.append(&mut self.current); return Ok(()); diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs index 6e9941bb98..7e090ccba4 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; use core_types::activity::ActivityChunk; @@ -110,6 +110,24 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( mode: CodexTranscriptCollectionMode<'a>, start_offset: u64, initial_sequence: usize, +) -> Result { + parse_codex_app_bounded( + session_id, + path, + mode, + start_offset, + initial_sequence, + u64::MAX, + ) +} + +pub(super) fn parse_codex_app_bounded<'a>( + session_id: &'a str, + path: &Path, + mode: CodexTranscriptCollectionMode<'a>, + start_offset: u64, + initial_sequence: usize, + max_bytes: u64, ) -> Result { let mut file = fs::File::open(path) .map_err(|err| format!("Failed to open Codex history {}: {err}", path.display()))?; @@ -121,7 +139,7 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( ) })?; } - let mut reader = BufReader::new(file); + let mut reader = BufReader::new(file.take(max_bytes)); let mut collector = CodexTranscriptCollector::new(session_id, mode); let mut pending_tool_calls: imported_history::PendingCallMap> = @@ -170,6 +188,9 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( break; } next_byte_offset = next_byte_offset.saturating_add(bytes_read as u64); + if next_byte_offset.saturating_sub(start_offset) >= max_bytes { + return Err("Codex review exceeds transcript byte budget".into()); + } strip_ignored_embedded_images(&mut line); let trimmed = line.trim(); if trimmed.is_empty() { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/reader.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/reader.rs index 81969f6538..a3517ae26f 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/reader.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/reader.rs @@ -28,6 +28,61 @@ use super::CODEX_PROVIDER_SLUG; const CODEX_TURN_HEADER_PROBE_BYTES: u64 = 8 * 1024 * 1024; const CODEX_MOBILE_HISTORY_SCAN_MAX_BYTES: u64 = 32 * 1024 * 1024; +/// Bounded context for historical file replay in a large rollout. Seek near +/// the selected turn and include at most eight earlier turns / 32 MiB, then +/// stop after the turn containing the exact provider call. No whole-log scan. +pub fn load_codex_app_review_context_from_path( + session_id: &str, + path: &Path, + turn_id: &str, + call_id: &str, +) -> Result, String> { + let signature = codex_transcript_file_signature(path)?; + let offset = codex_turn_offset_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(path, signature, turn_id) + .map(|entry| entry.0) + .or_else(|| codex_lazy_turn_offset(turn_id)) + .filter(|offset| *offset < signature.size_bytes) + .ok_or("Review turn has no bounded source offset")?; + let previous = find_recent_codex_user_offsets_bounded(path, offset, 8, 32 * 1024 * 1024)?; + let start = previous + .iter() + .map(|e| e.byte_offset) + .min() + .unwrap_or(offset); + let (chunks, _, _) = super::parser::parse_codex_app_bounded( + session_id, + path, + CodexTranscriptCollectionMode::ReviewThroughCall { call_id }, + start, + codex_lazy_turn_sequence(start), + 64 * 1024 * 1024, + )?; + if codex_transcript_file_signature(path)? != signature { + return Err("History changed during review; retry".into()); + } + Ok(chunks) +} + +/// Explicit review only: reject oversized histories instead of claiming an +/// incomplete suffix represents the whole session. Never updates raw history. +pub fn load_codex_app_review_from_path( + session_id: &str, + path: &Path, +) -> Result, String> { + let (chunks, _, _) = super::parser::parse_codex_app_bounded( + session_id, + path, + CodexTranscriptCollectionMode::Full, + 0, + 0, + 64 * 1024 * 1024, + )?; + Ok(chunks) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CodexAppInitialWindow { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/exec_results.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/exec_results.rs index 576a30cfa5..233c425216 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/exec_results.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/exec_results.rs @@ -5,6 +5,7 @@ pub(super) struct CodexExecResult { pub(super) output: String, pub(super) session_id: Option, pub(super) exit_code: Option, + pub(super) empty_tool_value: bool, } pub(super) fn append_incremental_output(existing: &mut String, next: &str) { @@ -74,6 +75,17 @@ fn codex_exec_results_from_value(value: Value) -> Vec { fn codex_exec_result_from_value(value: Value) -> Option { let object = value.as_object()?; + // apply_patch returns an empty successful value. Keep its position in a + // Desktop exec batch; dropping it assigns the following shell's exit/ + // background status to the already-completed edit. + if object.is_empty() { + return Some(CodexExecResult { + output: "{}".into(), + session_id: None, + exit_code: Some(0), + empty_tool_value: true, + }); + } if !object.contains_key("output") && !object.contains_key("session_id") && !object.contains_key("sessionId") @@ -83,6 +95,7 @@ fn codex_exec_result_from_value(value: Value) -> Option { return None; } Some(CodexExecResult { + empty_tool_value: false, output: object .get("output") .and_then(Value::as_str) diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs index dcca63eec4..4e39e20d9a 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs @@ -107,6 +107,17 @@ pub(super) fn resolve_codex_tool_outputs( background_tool_calls: &mut imported_history::PendingCallMap, ) { let mut results = codex_exec_results(output_value); + // An empty value has no explicit success flag. Do not promote it over an + // outer script failure; only typed per-command results can stand alone. + let envelope = fallback_output.trim_start(); + if envelope.starts_with("Script failed") || envelope.starts_with("Script error") { + for result in &mut results { + if result.empty_tool_value { + result.exit_code = Some(1); + result.output = fallback_output.to_owned(); + } + } + } if results.len() == calls.len() { for (call, result) in calls.into_iter().zip(results.drain(..)) { resolve_codex_call_group( @@ -121,7 +132,7 @@ pub(super) fn resolve_codex_tool_outputs( } return; } - if results.len() == 1 { + if results.len() == 1 && !results[0].empty_tool_value { resolve_codex_call_group( transcript_session_id, calls, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs index 2cfd84f9b1..33794c71cc 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs @@ -1,6 +1,60 @@ use super::*; use crate::sources::imported_history::client_origin::ImportedClientOrigin; +#[test] +fn codex_exec_empty_patch_result_stays_completed_before_shell_failure_or_background() { + let dir = std::env::temp_dir().join(format!("orgii-mixed-results-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("mixed-results.jsonl"); + for (shell, envelope, patch_success) in [ + (json!({"exit_code":1,"output":"failed"}), "Script completed", true), + (json!({"session_id":71,"output":"running"}), "Script completed", true), + (json!({"exit_code":1,"output":"failed"}), "Script failed", false), + ] { + let patch = "*** Begin Patch\n*** Add File: a\n+whole file\n*** End Patch"; + let script = format!( + "text(await tools.apply_patch({})); text(await tools.exec_command({{cmd:\"check\"}}));", + serde_json::to_string(patch).unwrap() + ); + let lines = [ + json!({"type":"response_item","payload":{"type":"custom_tool_call","name":"exec","call_id":"mixed","input":script}}), + json!({"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"mixed","output":[{"type":"input_text","text":envelope},{"type":"input_text","text":"{}"},{"type":"input_text","text":shell.to_string()}]}}), + ]; + std::fs::write( + &path, + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + let chunks = load_codex_app_from_path("codexapp-mixed", &path).unwrap(); + let edit = chunks + .iter() + .find(|chunk| chunk.result["call_id"] == "mixed:part-0") + .expect("completed patch"); + assert_eq!(edit.result["success"], patch_success); + assert_eq!(edit.result["status"], if patch_success { "completed" } else { "failed" }); + assert_eq!(edit.result["exit_code"], if patch_success { 0 } else { 1 }); + assert_eq!( + chunks[0].result["call_id"], "mixed:part-0", + "a following shell must not delay the edit" + ); + let command = chunks + .iter() + .find(|chunk| chunk.result["call_id"] == "mixed:part-1") + .unwrap(); + if shell.get("exit_code").is_some() { + assert_eq!(command.result["success"], false); + } else { + assert_ne!(command.result["status"], "completed"); + } + } + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); +} + #[test] fn includes_codex_session_dir_candidates() { let home = std::path::Path::new("/Users/example"); @@ -1794,6 +1848,50 @@ fn codex_rollout_without_session_start_still_recovers_exec_apply_patch() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn codex_metadata_counts_only_completed_exec_patches() { + let path = + std::env::temp_dir().join(format!("rollout-exec-impact-{}.jsonl", std::process::id())); + let patch = "*** Begin Patch\n*** Add File: src/a.ts\n+hello\n*** End Patch"; + let call = json!({"timestamp":"2026-09-11T00:00:00Z", "payload": { + "type":"custom_tool_call", "name":"exec", "call_id":"c1", + "input":format!("text(await tools.apply_patch({}));", serde_json::to_string(patch).unwrap()) + }}); + for (output, expected) in [ + ("Script completed\nOutput:\n{}", 1), + ("Script failed", 0), + ("", 0), + ] { + std::fs::write( + &path, + format!( + "{}\n{}\n", + call, + json!({"payload": { + "type":"custom_tool_call_output", "call_id":"c1", + "output":[{"type":"input_text","text":output}] + }}) + ), + ) + .unwrap(); + let (source_mtime_ms, source_size_bytes) = + imported_paths::file_metadata_signature(&path, "Codex").unwrap(); + let record = ImportedHistoryDiscoveredRecord { + source_session_id: "rollout-exec-impact".into(), + source_path: path.clone(), + source_record_key: "rollout-exec-impact".into(), + source_mtime_ms, + source_size_bytes, + source_fingerprint: String::new(), + parser_version: CODEX_APP_METADATA_PARSER_VERSION, + }; + let meta = parse_codex_session_meta(&record).unwrap().unwrap(); + assert_eq!(meta.impact.files_changed, expected); + assert_eq!(meta.impact.lines_added, expected); + } + std::fs::remove_file(path).unwrap(); +} + #[test] fn codex_desktop_exec_unwraps_web_search_query() { let payload = json!({ diff --git a/src-tauri/src/agent_sessions/cli/hook_approvals.rs b/src-tauri/src/agent_sessions/cli/hook_approvals.rs index cf58d0904f..89e481ecb0 100644 --- a/src-tauri/src/agent_sessions/cli/hook_approvals.rs +++ b/src-tauri/src/agent_sessions/cli/hook_approvals.rs @@ -69,6 +69,37 @@ impl HookApprovalDecision { struct PendingHookApproval { session_id: String, sender: oneshot::Sender, + tool_name: String, + tool_args: serde_json::Value, + created_at_ms: i64, +} + +pub fn pending_snapshot_for_session( + session_id: Option<&str>, + limit: usize, +) -> Result, String> { + let pending = PENDING_HOOK_APPROVALS + .lock() + .map_err(|_| "Hook approval registry lock is poisoned")?; + Ok(pending + .iter() + .filter(|(_, entry)| session_id.is_none_or(|id| entry.session_id == id)) + .take(limit) + .map(|(id, entry)| { + serde_json::json!({ + "kind": "permission", "origin": "cli_hook", "sessionId": entry.session_id, + "requestId": id, "toolName": entry.tool_name, "toolCallId": id, + "toolArgs": entry.tool_args, "createdAtMs": entry.created_at_ms, + }) + }) + .collect()) +} + +fn pending_changed(session_id: &str) { + websocket_handler::broadcast( + serde_json::json!({"type": "permission:pending_changed", "session_id": session_id}) + .to_string(), + ); } /// Pending hook approvals keyed by `request_id`. @@ -99,26 +130,7 @@ pub fn unregister_session(session_id: &str) { if let Ok(mut pending) = PENDING_HOOK_APPROVALS.lock() { pending.retain(|_, entry| entry.session_id != session_id); } -} - -fn session_permission_mode(session_id: &str) -> Option { - SESSION_PERMISSION_MODES - .lock() - .ok() - .and_then(|map| map.get(session_id).copied()) -} - -/// Whether hook approvals should block for this session. -/// -/// Only `Manual` launches get an interactive gate: AutoEdit / -/// FullPermission / Plan flags already tell Claude what to auto-approve, -/// and an unknown session (restarted desktop, external session that -/// spoofed the env var) must never be blocked by a GUI it can't reach. -fn session_wants_interactive_approval(session_id: &str) -> bool { - matches!( - session_permission_mode(session_id), - Some(CliPermissionMode::Manual) - ) + pending_changed(session_id); } fn broadcast_permission_request( @@ -154,23 +166,32 @@ pub async fn park_hook_approval( tool_args: serde_json::Value, timeout: Duration, ) -> HookApprovalDecision { - if !session_wants_interactive_approval(session_id) { - return HookApprovalDecision::Passthrough; - } - let request_id = format!("hookperm-{}", uuid::Uuid::new_v4()); let (tx, rx) = oneshot::channel::(); - match PENDING_HOOK_APPROVALS.lock() { - Ok(mut pending) => { - pending.insert( - request_id.clone(), - PendingHookApproval { - session_id: session_id.to_string(), - sender: tx, - }, - ); + { + // Hold launch ownership through insertion: unregister cannot finish + // between a Manual check and parking a new request for that session. + let Ok(modes) = SESSION_PERMISSION_MODES.lock() else { + return HookApprovalDecision::Passthrough; + }; + if !matches!(modes.get(session_id), Some(CliPermissionMode::Manual)) { + return HookApprovalDecision::Passthrough; + } + match PENDING_HOOK_APPROVALS.lock() { + Ok(mut pending) => { + pending.insert( + request_id.clone(), + PendingHookApproval { + session_id: session_id.to_string(), + sender: tx, + tool_name: tool_name.to_string(), + tool_args: tool_args.clone(), + created_at_ms: chrono::Utc::now().timestamp_millis(), + }, + ); + } + Err(_) => return HookApprovalDecision::Passthrough, } - Err(_) => return HookApprovalDecision::Passthrough, } let _lifetime = super::permission_lifecycle::PermissionLifetime::new( @@ -202,8 +223,9 @@ pub async fn park_hook_approval( } fn remove_pending_hook_approval(request_id: &str) { - if let Ok(mut pending) = PENDING_HOOK_APPROVALS.lock() { - pending.remove(request_id); + let removed = PENDING_HOOK_APPROVALS.lock().ok().and_then(|mut pending| pending.remove(request_id)); + if let Some(approval) = removed { + pending_changed(&approval.session_id); } } @@ -226,8 +248,7 @@ pub fn resolve_hook_approval( .get(request_id) .filter(|entry| entry.session_id == session_id) .map(|_| request_id.to_string()), - // No request id: fall back to the session's only - // pending entry — Claude blocks on one permission at a time. + // Only legacy callers omitting an ID may select by session. None => pending .iter() .find(|(_, entry)| entry.session_id == session_id) @@ -237,6 +258,7 @@ pub fn resolve_hook_approval( key.ok_or_else(|| format!("No pending hook approval for session {session_id}"))?; pending.remove(&key).expect("key was just found") }; + pending_changed(session_id); let decision = if approved { HookApprovalDecision::Allow @@ -460,4 +482,80 @@ mod tests { assert_resolved(&session); unregister_session(&session); } + + #[tokio::test] + async fn stale_foreign_and_cancelled_requests_cannot_resolve_another_prompt() { + let session = unique_session("scoped-snapshot"); + register_session_permission_mode(&session, CliPermissionMode::Manual); + let park = tokio::spawn({ + let session = session.clone(); + async move { + park_hook_approval( + &session, + "Bash", + serde_json::json!({"command":"pwd"}), + Duration::from_secs(10), + ) + .await + } + }); + for _ in 0..100 { + if has_pending_hook_approval(&session, None) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let rows = pending_snapshot_for_session(Some(&session), 10).unwrap(); + assert_eq!(rows.len(), 1); + let id = rows[0]["requestId"].as_str().unwrap(); + assert_eq!(rows[0]["toolArgs"]["command"], "pwd"); + assert!(resolve_hook_approval(&session, Some("stale"), true).is_err()); + assert!(resolve_hook_approval("foreign", Some(id), true).is_err()); + assert!(!has_pending_hook_approval("foreign", Some(id))); + assert_eq!( + pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .len(), + 1 + ); + park.abort(); + let _ = park.await; + assert!(pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .is_empty()); + unregister_session(&session); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_session_end_cannot_leave_a_new_parked_request() { + for _ in 0..32 { + let session = unique_session("unregister-race"); + register_session_permission_mode(&session, CliPermissionMode::Manual); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let park = tokio::spawn({ + let session = session.clone(); + let barrier = barrier.clone(); + async move { + barrier.wait(); + park_hook_approval( + &session, + "Bash", + serde_json::json!({}), + Duration::from_secs(5), + ) + .await + } + }); + barrier.wait(); + unregister_session(&session); + let decision = tokio::time::timeout(Duration::from_secs(1), park) + .await + .expect("session end releases pending wait") + .unwrap(); + assert_eq!(decision, HookApprovalDecision::Passthrough); + assert!(pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .is_empty()); + } + } } diff --git a/src-tauri/src/agent_sessions/cli/parsers/acp_common/approval.rs b/src-tauri/src/agent_sessions/cli/parsers/acp_common/approval.rs index eb7412b55a..b2c8975c35 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/acp_common/approval.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/acp_common/approval.rs @@ -25,6 +25,10 @@ pub struct ApprovalResponse { struct PendingAcpApproval { session_id: String, sender: oneshot::Sender, + tool_name: String, + tool_args: Value, + tool_call_id: String, + created_at_ms: i64, } type PendingApprovalsMap = HashMap; @@ -35,6 +39,48 @@ type PendingApprovalsMap = HashMap; static PENDING_APPROVALS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); +pub fn pending_snapshot_for_session( + session_id: Option<&str>, + limit: usize, +) -> Result, String> { + let pending = PENDING_APPROVALS + .lock() + .map_err(|_| "ACP approval registry lock is poisoned")?; + Ok(pending + .iter() + .filter(|(_, entry)| session_id.is_none_or(|id| entry.session_id == id)) + .take(limit) + .map(|(id, entry)| { + serde_json::json!({ + "kind": "permission", "origin": "acp", "sessionId": entry.session_id, + "requestId": id, "toolName": entry.tool_name, "toolCallId": entry.tool_call_id, + "toolArgs": entry.tool_args, "createdAtMs": entry.created_at_ms, + }) + }) + .collect()) +} + +fn pending_changed(session_id: &str) { + crate::api::websocket_handler::broadcast( + serde_json::json!({"type": "permission:pending_changed", "session_id": session_id}) + .to_string(), + ); +} + +/// Lives with the protocol wait, including cancellation of its owning future. +pub(super) struct AcpApprovalGuard(String); +impl Drop for AcpApprovalGuard { + fn drop(&mut self) { + let entry = PENDING_APPROVALS + .lock() + .ok() + .and_then(|mut pending| pending.remove(&self.0)); + if let Some(entry) = entry { + pending_changed(&entry.session_id); + } + } +} + /// How long an ACP permission request waits for the user before the /// legacy auto-approve fallback fires. pub const ACP_APPROVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); @@ -55,7 +101,7 @@ pub async fn resolve_approval( let entry = { let mut pending = PENDING_APPROVALS .lock() - .expect("ACP approval registry poisoned"); + .map_err(|_| "ACP approval registry lock is poisoned")?; let key = match request_id { Some(request_id) => pending .get(request_id) @@ -69,6 +115,7 @@ pub async fn resolve_approval( let key = key.ok_or_else(|| format!("No pending approval for session {}", session_id))?; pending.remove(&key).expect("key was just found") }; + pending_changed(session_id); entry .sender .send(ApprovalResponse { @@ -83,6 +130,9 @@ pub async fn resolve_approval( /// the protocol loop awaits on. pub(super) async fn register_acp_approval( session_id: &str, + tool_name: &str, + tool_args: &Value, + tool_call_id: Option<&str>, ) -> ( String, oneshot::Receiver, @@ -98,21 +148,21 @@ pub(super) async fn register_acp_approval( PendingAcpApproval { session_id: session_id.to_string(), sender: tx, + tool_name: tool_name.to_string(), + tool_args: tool_args.clone(), + tool_call_id: tool_call_id.unwrap_or(&request_id).to_string(), + created_at_ms: chrono::Utc::now().timestamp_millis(), }, ); + pending_changed(session_id); let lifetime = crate::agent_sessions::cli::permission_lifecycle::PermissionLifetime::new( - session_id, - &request_id, - remove_pending_acp_approval, + session_id, &request_id, remove_pending_acp_approval, ); (request_id, rx, lifetime) } fn remove_pending_acp_approval(request_id: &str) { - PENDING_APPROVALS - .lock() - .expect("ACP approval registry poisoned") - .remove(request_id); + drop(AcpApprovalGuard(request_id.to_string())); } /// Await a parked approval. On timeout or a dropped sender the legacy @@ -126,10 +176,7 @@ pub(super) async fn await_acp_approval( Ok(Ok(resp)) => resp, _ => { tracing::info!("[ACP] Approval timed out or channel closed — auto-approving"); - PENDING_APPROVALS - .lock() - .expect("ACP approval registry poisoned") - .remove(request_id); + drop(AcpApprovalGuard(request_id.to_string())); ApprovalResponse { approved: true, always_allow: false, @@ -297,7 +344,7 @@ mod lifecycle_tests { async fn permission_lifetime_cleans_timeout_response_and_cancel() { for mode in ["timeout", "response", "cancel"] { let session = format!("acp-lifetime-{}", uuid::Uuid::new_v4()); - let (id, rx, lifetime) = register_acp_approval(&session).await; + let (id, rx, lifetime) = register_acp_approval(&session, "read_file", &Value::Null, None).await; assert!(resolve_approval(&session, Some("stale-id"), true, false) .await .is_err()); @@ -335,3 +382,58 @@ mod lifecycle_tests { } } } + +#[cfg(test)] +mod pending_tests { + use super::*; + + #[tokio::test] + async fn scoped_snapshot_rejects_stale_foreign_and_duplicate_replies() { + let session = format!("acp-test-{}", uuid::Uuid::new_v4()); + let (id, rx, guard) = register_acp_approval( + &session, + "read_file", + &serde_json::json!({"path":"a.rs"}), + Some("call-a"), + ) + .await; + let rows = pending_snapshot_for_session(Some(&session), 10).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["toolCallId"], "call-a"); + assert!(resolve_approval(&session, Some("stale"), true, true) + .await + .is_err()); + assert!(resolve_approval("foreign", Some(&id), true, true) + .await + .is_err()); + assert_eq!( + pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .len(), + 1 + ); + resolve_approval(&session, Some(&id), false, false) + .await + .unwrap(); + assert!(!rx.await.unwrap().approved); + assert!(resolve_approval(&session, Some(&id), true, true) + .await + .is_err()); + drop(guard); + assert!(pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn dropped_owner_removes_recoverable_request() { + let session = format!("acp-test-{}", uuid::Uuid::new_v4()); + let (_id, rx, guard) = + register_acp_approval(&session, "read_file", &Value::Null, None).await; + drop(guard); + assert!(rx.await.is_err()); + assert!(pending_snapshot_for_session(Some(&session), 10) + .unwrap() + .is_empty()); + } +} diff --git a/src-tauri/src/agent_sessions/cli/parsers/acp_common/mod.rs b/src-tauri/src/agent_sessions/cli/parsers/acp_common/mod.rs index ea7abaec25..f440ee2641 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/acp_common/mod.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/acp_common/mod.rs @@ -20,7 +20,9 @@ use serde_json::Value; use core_types::activity::ActivityChunk; -pub use approval::{resolve_approval, ApprovalResponse, ACP_APPROVAL_TIMEOUT}; +pub use approval::{ + pending_snapshot_for_session, resolve_approval, ApprovalResponse, ACP_APPROVAL_TIMEOUT, +}; pub(crate) use parser::AcpNotificationParser; pub use protocol::run_acp_protocol; diff --git a/src-tauri/src/agent_sessions/cli/parsers/acp_common/protocol.rs b/src-tauri/src/agent_sessions/cli/parsers/acp_common/protocol.rs index d9dd2a8ff2..dac77f655a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/acp_common/protocol.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/acp_common/protocol.rs @@ -431,7 +431,9 @@ async fn process_notification( // Register the oneshot BEFORE broadcasting so a fast // frontend response always finds the entry. - let (request_id, rx, permission_lifetime) = register_acp_approval(session_id).await; + let (request_id, rx, permission_lifetime) = register_acp_approval( + session_id, &tool_name, &info.tool_args, info.tool_call_id.as_deref(), + ).await; // Emit an ask_user_permissions chunk (transcript record) let mut chunk = diff --git a/src-tauri/src/api/mobile_bridge/adapters/change_review.rs b/src-tauri/src/api/mobile_bridge/adapters/change_review.rs new file mode 100644 index 0000000000..8fe4c0fbdd --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/adapters/change_review.rs @@ -0,0 +1,449 @@ +//! Read-only, bounded historical change review. Never reads today's files to +//! fill missing historical snapshots, and never labels summed edits as net diff. + +use std::collections::BTreeMap; + +use database::db::get_connection; +use orgtrack_core::canonical::SessionDiffChunkRecord; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::mobile_bridge::rpc::RpcError; +use crate::orgtrack::imported_changes::reconstruction::{self, FileVersion, RecordedEdit}; + +const MAX_RECORDS: usize = 500; +const MAX_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Params { + session_id: String, + round_id: Option, + file_path: Option, + scope: Scope, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +enum Scope { + Turn, + Session, + Workspace, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeFile { + path: String, + additions: Option, + deletions: Option, + /// Ordered edit patches; NOT a fabricated whole-file or net snapshot. + patches: Vec, + before: Option, + after: Option, + availability: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeReview { + files: Vec, + complete: bool, +} + +fn attach_version(file: &mut ChangeFile, version: FileVersion) { + if version.before.is_some() || version.after.is_some() { + file.before = version.before; + file.after = version.after; + file.availability = "reconstructed_from_history"; + } +} + +fn project(chunks: Vec) -> ChangeReview { + let mut grouped: BTreeMap> = BTreeMap::new(); + for chunk in chunks { + grouped + .entry(chunk.file_path.clone()) + .or_default() + .push(chunk); + } + let files = grouped + .into_iter() + .map(|(path, chunks)| { + // Even exact chunk contents may be local old_string/new_string spans. + // Multiple patches may overlap or reverse each other; summing their + // counters would violate the net-change contract. + let single = (chunks.len() == 1) + .then(|| &chunks[0]) + .and_then(|c| c.diff.as_deref()) + .filter(|d| !d.is_empty()) + .and_then(super::change_review_stats::numstat); + let patches = chunks + .iter() + .filter_map(|chunk| chunk.diff.clone()) + .collect(); + ChangeFile { + path, + additions: single.map(|(added, _)| added), + deletions: single.map(|(_, removed)| removed), + patches, + before: None, + after: None, + availability: "patch_only", + } + }) + .collect(); + ChangeReview { + files, + complete: false, + } +} + +/// Read historical artifacts without claiming partial fragments are full files. +pub async fn historical_changes(params: &Value) -> Result { + let params: Params = serde_json::from_value(params.clone()) + .map_err(|_| RpcError::invalid_params("invalid historical change request"))?; + for value in std::iter::once(¶ms.session_id) + .chain(params.round_id.iter()) + .chain(params.file_path.iter()) + { + if value.trim().is_empty() || value.len() > 1024 { + return Err(RpcError::invalid_params( + "invalid session or round identity", + )); + } + } + if params.scope == Scope::Workspace { + return super::workspace_changes::read(¶ms.session_id, params.file_path.as_deref()) + .await; + } + if params.scope == Scope::Turn && params.round_id.is_none() { + return Err(RpcError::invalid_params( + "roundId is required for turn scope", + )); + } + if params.scope == Scope::Turn { + // The transcript is the authority for a round, including imported + // history not yet materialized in the orgtrack artifact index. + let round = params.round_id.as_deref().unwrap(); + let events = super::session::authoritative_round_events(¶ms.session_id, round).await?; + let mut chunks = Vec::new(); + let mut bytes = 0; + let mut first_call = None; + let mut history = Vec::new(); + for (index, event) in events.into_iter().enumerate() { + if event.display_status != core_types::session_event::EventDisplayStatus::Completed { + continue; + } + if let Some(core_types::extracted::ExtractedData::Edit(ref edit)) = event.extracted { + let context = orgtrack_core::edit_extraction::EditArtifactContext { + source: "mobile_round_projection".into(), + source_session_id: None, + session_id: params.session_id.clone(), + source_event_id: Some(event.id.clone()), + turn_id: Some(round.to_owned()), + sequence_index: index as i64, + timestamp: None, + workspace_path: None, + metadata: Default::default(), + }; + let artifacts = + orgtrack_core::edit_extraction::artifacts_from_extracted_edit(&context, edit); + if params.file_path.is_some() { + history.push(RecordedEdit::from_event( + &event, + artifacts + .chunks + .iter() + .map(|c| c.file_path.clone()) + .collect(), + )); + bytes += history + .last() + .and_then(|e| e.patch.as_ref()) + .map_or(0, String::len); + } + for chunk in artifacts.chunks { + bytes += chunk.diff.as_ref().map_or(0, String::len) + + chunk.old_content.as_ref().map_or(0, String::len) + + chunk.new_content.as_ref().map_or(0, String::len); + if bytes > MAX_BYTES || chunks.len() >= MAX_RECORDS { + return Err(RpcError::invalid_params("round exceeds review budget")); + } + if params + .file_path + .as_ref() + .is_none_or(|path| path == &chunk.file_path) + { + if chunks.is_empty() { + first_call = event.call_id.clone(); + } + chunks.push(chunk); + } + } + } + } + let selected: Vec<_> = history + .iter() + .filter(|e| { + params + .file_path + .as_ref() + .is_some_and(|path| e.paths.contains(path)) + }) + .collect(); + let range = selected + .first() + .map(|e| e.event_id.clone()) + .zip(selected.last().map(|e| e.event_id.clone())); + let mut result = project(chunks); + if let (Some(path), Some(range)) = (params.file_path.as_ref(), range) { + let id = params.session_id.clone(); + let path = path.clone(); + let round = round.to_owned(); + let version = tokio::task::spawn_blocking(move || -> Result { + let before = first_call.and_then(|call| { + agent_core::tools::file_history::read_pre_tool_file(&id, &call, &path) + }); + let local = reconstruction::replay(&history, &path, None, before); + if local.before.is_some() || local.after.is_some() { + return Ok(local); + } + let conn = get_connection().map_err(|e| e.to_string())?; + crate::orgtrack::imported_changes::read_file_version( + &conn, + &id, + &path, + Some((&round, &range.0, &range.1)), + ) + }) + .await + .map_err(|_| RpcError::invalid_params("snapshot reader failed"))? + .map_err(RpcError::invalid_params)?; + if let Some(file) = result.files.first_mut() { + attach_version(file, version); + } + } + if params.file_path.is_none() { + for file in &mut result.files { + file.patches.clear(); + } + } + return serde_json::to_value(result) + .map_err(|_| RpcError::invalid_params("change serialization failed")); + } + let review = tokio::task::spawn_blocking(move || -> Result { + let conn = get_connection().map_err(|error| error.to_string())?; + load_history(&conn, ¶ms) + }) + .await + .map_err(|_| RpcError::invalid_params("change reader failed"))? + .map_err(RpcError::invalid_params)?; + serde_json::to_value(review) + .map_err(|_| RpcError::invalid_params("change serialization failed")) +} + +fn load_history(conn: &rusqlite::Connection, params: &Params) -> Result { + if params.scope == Scope::Session { + if let Some(imported) = crate::orgtrack::imported_changes::read(conn, ¶ms.session_id)? { + let mut review = project( + imported + .chunks + .into_iter() + .filter(|chunk| { + params + .file_path + .as_ref() + .is_none_or(|path| path == &chunk.file_path) + }) + .collect(), + ); + if params.file_path.is_none() { + for file in &mut review.files { + file.patches.clear(); + } + } else if let Some(file) = review.files.first_mut() { + let version = crate::orgtrack::imported_changes::read_file_version( + conn, + ¶ms.session_id, + &file.path, + None, + )?; + attach_version(file, version); + } + return Ok(review); + } + } + let round_id = if params.scope == Scope::Turn { + params.round_id.clone() + } else { + None + }; + // Bound payload size in SQL before materializing JSON in the process. + // Select only the exact owner; never query every session then filter. + let mut statement = conn.prepare( + "SELECT CASE WHEN length(c.payload_json) <= 2097152 THEN c.payload_json ELSE NULL END FROM orgtrack_core_diff_chunks c + WHERE c.session_id = ?1 AND (?2 IS NULL OR EXISTS ( + SELECT 1 FROM orgtrack_core_edit_artifacts e + WHERE e.record_id = c.edit_record_id + AND e.session_id = c.session_id + AND json_extract(e.payload_json, '$.turnId') = ?2 + )) AND (?3 IS NULL OR c.file_path = ?3) + ORDER BY c.sequence_index, c.chunk_index LIMIT ?4" + ).map_err(|error| error.to_string())?; + let rows = statement + .query_map( + rusqlite::params![ + params.session_id, + round_id, + params.file_path, + (MAX_RECORDS + 1) as i64 + ], + |row| row.get::<_, String>(0), + ) + .map_err(|error| error.to_string())?; + let mut chunks = Vec::new(); + let mut bytes = 0; + for row in rows { + let payload = row.map_err(|error| error.to_string())?; + bytes += payload.len(); + if bytes > MAX_BYTES || chunks.len() == MAX_RECORDS { + return Err("historical change window exceeds review budget".into()); + } + chunks.push( + serde_json::from_str(&payload) + .map_err(|error| format!("invalid change artifact: {error}"))?, + ); + } + let mut review = project(chunks); + for file in &mut review.files { + if params.file_path.as_deref() != Some(&file.path) { + file.patches.clear(); + } + } + Ok(review) +} + +#[cfg(test)] +mod tests { + use super::*; + use orgtrack_core::{ + canonical::{AgentMetadata, ArtifactQuality, SessionEditArtifactRecord, SessionEditKind}, + store::{sqlite::SqliteRecordStore, RecordStore}, + }; + + #[test] + fn reconstructed_version_is_serialized_as_full_content_not_patch_fragments() { + let mut file = ChangeFile { + path: "a".into(), + additions: Some(1), + deletions: Some(1), + patches: vec!["fragment".into()], + before: None, + after: None, + availability: "patch_only", + }; + attach_version( + &mut file, + FileVersion { + before: Some("head\nold\ntail\n".into()), + after: Some("head\nnew\ntail\n".into()), + }, + ); + let wire = serde_json::to_value(&file).unwrap(); + assert_eq!(wire["after"], "head\nnew\ntail\n"); + assert_eq!(wire["before"], "head\nold\ntail\n"); + assert_eq!(wire["availability"], "reconstructed_from_history"); + attach_version( + &mut file, + FileVersion { + before: None, + after: Some(String::new()), + }, + ); + assert_eq!(serde_json::to_value(&file).unwrap()["after"], ""); + } + + fn seed(conn: &rusqlite::Connection, owner: &str, turn: &str, index: i64) { + let store = SqliteRecordStore::new(conn); + let id = format!("{owner}:{turn}:{index}"); + store + .upsert_edit_artifact(&SessionEditArtifactRecord { + schema_version: 1, + record_id: id.clone(), + source: "fixture".into(), + source_session_id: None, + session_id: owner.into(), + source_event_id: Some(id.clone()), + turn_id: Some(turn.into()), + sequence_index: index, + timestamp: None, + workspace_path: None, + file_path: "file.ts".into(), + path_hash: "fixture".into(), + edit_kind: SessionEditKind::Patch, + old_start_line: Some(1), + new_start_line: Some(1), + start_line: Some(1), + end_line: Some(1), + lines_added: 1, + lines_removed: 1, + quality: ArtifactQuality::Exact, + metadata: AgentMetadata::default(), + }) + .unwrap(); + store + .upsert_diff_chunk(&SessionDiffChunkRecord { + schema_version: 1, + record_id: id.clone(), + edit_record_id: id.clone(), + source: "fixture".into(), + session_id: owner.into(), + source_event_id: Some(id), + sequence_index: index, + chunk_index: 0, + file_path: "file.ts".into(), + old_start_line: Some(1), + new_start_line: Some(1), + old_content: Some("a".into()), + new_content: Some("b".into()), + diff: Some("@@ -1 +1 @@\n-a\n+b\n".into()), + lines_added: 1, + lines_removed: 1, + is_deleted: false, + quality: ArtifactQuality::Exact, + }) + .unwrap(); + } + + #[test] + fn owner_and_round_are_scoped_and_fragments_never_become_full_files() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + SqliteRecordStore::init_tables(&conn).unwrap(); + seed(&conn, "a", "one", 1); + seed(&conn, "a", "two", 2); + seed(&conn, "b", "one", 3); + let mut params = Params { + session_id: "a".into(), + round_id: Some("one".into()), + file_path: Some("file.ts".into()), + scope: Scope::Turn, + }; + let round = load_history(&conn, ¶ms).unwrap(); + assert_eq!(round.files.len(), 1); + assert_eq!(round.files[0].patches.len(), 1); + assert_eq!(round.files[0].additions, Some(1)); + assert!(round.files[0].before.is_none()); + assert!(round.files[0].after.is_none()); + params.scope = Scope::Session; + let session = load_history(&conn, ¶ms).unwrap(); + assert_eq!(session.files[0].patches.len(), 2); + assert_eq!(session.files[0].additions, None); // Not summed as net. + params.file_path = None; + assert!(load_history(&conn, ¶ms).unwrap().files[0] + .patches + .is_empty()); + params.file_path = Some("unrelated.ts".into()); + assert!(load_history(&conn, ¶ms).unwrap().files.is_empty()); + } +} diff --git a/src-tauri/src/api/mobile_bridge/adapters/change_review_stats.rs b/src-tauri/src/api/mobile_bridge/adapters/change_review_stats.rs new file mode 100644 index 0000000000..ea332d5752 --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/adapters/change_review_stats.rs @@ -0,0 +1,46 @@ +//! Count only unified-diff body lines. A code line beginning with `++` or +//! `--` must not be mistaken for a file header; binary counts stay unknown. +pub(super) fn numstat(patch: &str) -> Option<(i32, i32)> { + if patch.is_empty() { + return Some((0, 0)); + } + let mut in_hunk = false; + let mut saw_hunk = false; + let mut added = 0; + let mut removed = 0; + for line in patch.lines() { + if line.starts_with("Binary files ") || line == "GIT binary patch" { + return None; + } + if line.starts_with("diff --git ") { + in_hunk = false; + } else if line.starts_with("@@ ") && line.contains(" @@") { + in_hunk = true; + saw_hunk = true; + } else if in_hunk { + match line.as_bytes().first() { + Some(b'+') => added += 1, + Some(b'-') => removed += 1, + Some(b' ' | b'\\') => {} + _ => return None, + } + } + } + saw_hunk.then_some((added, removed)) +} + +#[cfg(test)] +mod tests { + use super::numstat; + + #[test] + fn headers_are_not_code_and_binary_is_not_zero() { + assert_eq!( + numstat("--- a\n+++ b\n@@ -1 +1 @@\n---old\n+++new\n"), + Some((1, 1)) + ); + assert_eq!(numstat("Binary files a and b differ\n"), None); + assert_eq!(numstat("rename from a\nrename to b\n"), None); + assert_eq!(numstat(""), Some((0, 0))); + } +} diff --git a/src-tauri/src/api/mobile_bridge/adapters/file_navigation.rs b/src-tauri/src/api/mobile_bridge/adapters/file_navigation.rs index a903193346..bff611a451 100644 --- a/src-tauri/src/api/mobile_bridge/adapters/file_navigation.rs +++ b/src-tauri/src/api/mobile_bridge/adapters/file_navigation.rs @@ -104,7 +104,7 @@ fn file_target_from_event( } } -fn session_workspace_root(session_id: &str) -> Result { +pub(super) fn session_workspace_root(session_id: &str) -> Result { let filter = SessionFilter { session_ids: Some(vec![session_id.to_string()]), limit: Some(1), diff --git a/src-tauri/src/api/mobile_bridge/adapters/images.rs b/src-tauri/src/api/mobile_bridge/adapters/images.rs new file mode 100644 index 0000000000..50360f4de7 --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/adapters/images.rs @@ -0,0 +1,324 @@ +//! Read-only, event-scoped image previews. Never accept a caller-supplied path. +use std::io::{BufRead, BufReader, Cursor, Read}; +use std::path::Path; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::agent_sessions::session_directory::aggregation::list_all_sessions; +use crate::agent_sessions::session_directory::types::SessionFilter; +use crate::api::mobile_bridge::rpc::RpcError; + +const MAX_IMAGES: usize = 8; +const MAX_INPUT: usize = 12 * 1024 * 1024; +const MAX_PREVIEW: usize = 384 * 1024; +const MAX_SCAN: u64 = 256 * 1024 * 1024; +static IMAGE_WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ImageParams { + session_id: String, + round_id: String, + event_id: String, + image_index: usize, +} + +fn refs(result: &Value) -> impl Iterator { + result + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .take(MAX_IMAGES) +} + +pub(super) fn image_count(result: &Value) -> usize { + refs(result).count() +} + +fn invalid() -> RpcError { + RpcError::invalid_params("image is unavailable or unsupported") +} + +fn decode_data_url(value: &str) -> Result, RpcError> { + if value.len() > MAX_INPUT * 4 / 3 + 128 { + return Err(invalid()); + } + let (header, data) = value.split_once(',').ok_or_else(invalid)?; + if !matches!( + header, + "data:image/png;base64" | "data:image/jpeg;base64" | "data:image/webp;base64" + ) { + return Err(invalid()); + } + STANDARD.decode(data).map_err(|_| invalid()) +} + +/// Recover only an embedded user attachment whose image marker names the exact +/// event-owned reference. Do not search other sessions or parse tool outputs. +fn recover_embedded(reader: impl Read, image_ref: &str) -> Result, RpcError> { + let mut reader = BufReader::new(reader.take(MAX_SCAN)); + let mut line = Vec::new(); + let marker = format!("path=\"{image_ref}\""); + loop { + line.clear(); + // Bound individual rows too; discard oversized tool-output rows without + // materializing them. The entire scan is separately bounded. + let size = reader + .by_ref() + .take((MAX_INPUT * 2 + 1) as u64) + .read_until(b'\n', &mut line) + .map_err(|_| invalid())?; + if size == 0 { + return Err(invalid()); + } + if size > MAX_INPUT * 2 { + if line.last() == Some(&b'\n') { + continue; + } + loop { + let buf = reader.fill_buf().map_err(|_| invalid())?; + let end = buf.iter().position(|b| *b == b'\n'); + let n = end.map_or(buf.len(), |i| i + 1); + if n == 0 { + return Err(invalid()); + } + reader.consume(n); + if end.is_some() { + break; + } + } + continue; + } + // A cheap substring check avoids JSON allocations for unrelated rows. + if !line + .windows(image_ref.len()) + .any(|window| window == image_ref.as_bytes()) + { + continue; + } + let Ok(row) = serde_json::from_slice::(&line) else { + continue; + }; + if row["type"] != "response_item" || row["payload"]["role"] != "user" { + continue; + } + let Some(parts) = row["payload"]["content"].as_array() else { + continue; + }; + let mut matched = false; + for part in parts { + match part["type"].as_str() { + Some("input_text") => { + let text = part["text"].as_str().unwrap_or_default(); + if text.starts_with("" { + matched = false; + } + } + Some("input_image") if matched => { + return decode_data_url(part["image_url"].as_str().ok_or_else(invalid)?); + } + _ => {} + } + } + } +} + +fn load_bytes(session_id: &str, image_ref: &str) -> Result, RpcError> { + if image_ref.starts_with("data:") { + return decode_data_url(image_ref); + } + // Remote URLs are deliberately not fetched: no SSRF or credential forwarding. + let path = Path::new(image_ref); + if !path.is_absolute() { + return Err(invalid()); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + match options.open(path) { + Ok(file) => { + let metadata = file.metadata().map_err(|_| invalid())?; + if !metadata.is_file() || metadata.len() > MAX_INPUT as u64 { + return Err(invalid()); + } + let mut bytes = Vec::new(); + file.take((MAX_INPUT + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| invalid())?; + if bytes.len() > MAX_INPUT { + return Err(invalid()); + } + Ok(bytes) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let filter = SessionFilter { + session_ids: Some(vec![session_id.to_owned()]), + ..Default::default() + }; + let response = list_all_sessions(Some(&filter)).map_err(|_| invalid())?; + let row = response + .sessions + .into_iter() + .find(|row| row.session_id == session_id) + .ok_or_else(invalid)?; + let source = row.storage_path.ok_or_else(invalid)?; + // Only Codex JSONL has the response-item/image-marker contract above. + if row.external_history_source.as_deref() != Some("codex_app") + || !source.ends_with(".jsonl") + { + return Err(invalid()); + } + recover_embedded( + std::fs::File::open(source).map_err(|_| invalid())?, + image_ref, + ) + } + Err(_) => Err(invalid()), + } +} + +fn preview(bytes: &[u8]) -> Result { + let mut reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|_| invalid())?; + if !matches!( + reader.format(), + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) + ) { + return Err(invalid()); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(96 * 1024 * 1024); + reader.limits(limits); + let decoded = reader.decode().map_err(|_| invalid())?; + for edge in [1280, 960, 640, 320] { + let small = decoded.thumbnail(edge, edge).to_rgb8(); + let mut output = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 80) + .encode_image(&small) + .map_err(|_| invalid())?; + if output.len() <= MAX_PREVIEW { + return Ok( + json!({ "dataUrl": format!("data:image/jpeg;base64,{}", STANDARD.encode(output)), + "width": small.width(), "height": small.height() }), + ); + } + } + Err(invalid()) +} + +pub async fn session_image(params: &Value) -> Result { + let parsed: ImageParams = serde_json::from_value(params.clone()).map_err(|_| invalid())?; + if [&parsed.session_id, &parsed.round_id, &parsed.event_id] + .iter() + .any(|id| id.trim().is_empty() || id.len() > 1024) + || parsed.image_index >= MAX_IMAGES + { + return Err(invalid()); + } + // No unbounded queue; thumbnails are explicitly retriable by the caller. + let permit = IMAGE_WORK + .try_acquire() + .map_err(|_| RpcError::invalid_params("image service is busy; retry"))?; + let events = + super::session::authoritative_round_events(&parsed.session_id, &parsed.round_id).await?; + let event = events + .iter() + .find(|event| event.id == parsed.event_id) + .ok_or_else(invalid)?; + let image_ref = refs(&event.result) + .nth(parsed.image_index) + .ok_or_else(invalid)? + .to_owned(); + drop(events); + tokio::task::spawn_blocking(move || { + let _permit = permit; + preview(&load_bytes(&parsed.session_id, &image_ref)?) + }) + .await + .map_err(|_| invalid())? +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn rejects_client_paths_and_invalid_indexes_before_loading_history() { + let mut params = json!({"sessionId":"s", "roundId":"r", "eventId":"e", "imageIndex":0, "path":"/tmp/private.png"}); + assert!(session_image(¶ms).await.is_err()); + params.as_object_mut().unwrap().remove("path"); + params["imageIndex"] = json!(8); + assert!(session_image(¶ms).await.is_err()); + params["imageIndex"] = json!(-1); + assert!(session_image(¶ms).await.is_err()); + } + #[test] + fn local_image_reads_are_bounded_and_remote_urls_are_not_fetched() { + assert!(load_bytes("s", "https://localhost/private.png").is_err()); + assert!(load_bytes("s", "../private.png").is_err()); + let file = tempfile::NamedTempFile::new().unwrap(); + file.as_file().set_len((MAX_INPUT + 1) as u64).unwrap(); + assert!(load_bytes("s", file.path().to_str().unwrap()).is_err()); + } + #[test] + #[ignore = "requires an explicitly supplied local Codex transcript and image reference"] + fn local_history_image_recovery() { + let path = std::env::var("ORG2_TEST_IMAGE_TRANSCRIPT").expect("transcript fixture"); + let image_ref = std::env::var("ORG2_TEST_IMAGE_REF").expect("image reference"); + let bytes = recover_embedded(std::fs::File::open(path).unwrap(), &image_ref).unwrap(); + let result = preview(&bytes).unwrap(); + assert!(result["width"].as_u64().unwrap() > 0); + eprintln!("Recovered preview: {}x{}, {} encoded bytes", result["width"], result["height"], result["dataUrl"].as_str().unwrap().len()); + } + #[test] + fn reference_projection_is_bounded_and_has_no_payload() { + assert_eq!( + image_count(&json!({"images": [null, "", "/tmp/a.png", "data:image/png;base64,AA=="]})), + 2 + ); + assert_eq!(image_count(&json!({"images": vec!["x"; 100]})), MAX_IMAGES); + } + #[test] + fn recovers_only_the_matching_user_image_marker() { + let row = json!({"type":"response_item","payload":{"role":"user","content":[ + {"type":"input_text","text":""}, + {"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}, + {"type":"input_text","text":""}]}}) + .to_string(); + assert_eq!( + recover_embedded(row.as_bytes(), "/tmp/a.png").unwrap(), + b"hello" + ); + assert!(recover_embedded(row.as_bytes(), "/tmp/b.png").is_err()); + assert!(recover_embedded( + row.replace("\"user\"", "\"assistant\"").as_bytes(), + "/tmp/a.png" + ) + .is_err()); + } + #[test] + fn preview_rejects_text_and_svg_and_bounds_output() { + assert!(preview(b"secret text").is_err()); + assert!(preview(b"").is_err()); + let image = image::RgbImage::new(2000, 1000); + let mut png = Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let result = preview(png.get_ref()).unwrap(); + assert_eq!(result["width"], 1280); + assert!(result["dataUrl"].as_str().unwrap().len() < 512 * 1024 + 128); + } +} diff --git a/src-tauri/src/api/mobile_bridge/adapters/interaction.rs b/src-tauri/src/api/mobile_bridge/adapters/interaction.rs index c0327aeb8b..a1266136b5 100644 --- a/src-tauri/src/api/mobile_bridge/adapters/interaction.rs +++ b/src-tauri/src/api/mobile_bridge/adapters/interaction.rs @@ -121,15 +121,16 @@ pub async fn respond_permission(params: &Value) -> Result { ) })?; - session + let accepted = session .permission_manager - .respond( - &parsed.request_id, - parsed.response, - parsed.tool_name.as_deref(), - parsed.tool_args.as_ref(), - ) + .respond_for_session(&parsed.session_id, &parsed.request_id, parsed.response) .await; + if !accepted { + return Err(RpcError::new( + RpcErrorCode::InvalidRequest, + "permission request is no longer pending", + )); + } } MobilePermissionExecution::ManagedCli => { let (approved, always_allow) = cli_permission_decision(parsed.response); @@ -171,33 +172,200 @@ pub async fn pending(params: &Value) -> Result { let handle = crate::api::get_app_handle() .ok_or_else(|| RpcError::new(RpcErrorCode::InvalidRequest, "desktop agent not ready"))?; let state = handle.state::(); - let session = state.get_session(session_id).await.ok_or_else(|| { - RpcError::new( - RpcErrorCode::SessionNotFound, - format!("session not found: {session_id}"), + let mut interactions = match state.get_session(session_id).await { + Some(session) => { + session + .permission_manager + .pending_snapshot(PENDING_LIMIT + 1) + .await + } + None => Vec::new(), // CLI sessions are owned by their own registries. + }; + append_cli_pending(&mut interactions, Some(session_id))?; + Ok(enriched_snapshot_result(interactions).await) +} + +const PENDING_LIMIT: usize = 2000; + +fn append_cli_pending(rows: &mut Vec, session_id: Option<&str>) -> Result<(), RpcError> { + // Each owner bounds its projection; scoped snapshots filter at the owner, + // rather than losing a selected session behind an unrelated global cap. + for snapshot in [ + crate::agent_sessions::cli::hook_approvals::pending_snapshot_for_session( + session_id, + PENDING_LIMIT + 1, + ), + crate::agent_sessions::cli::parsers::acp_common::pending_snapshot_for_session( + session_id, + PENDING_LIMIT + 1, + ), + ] { + rows.extend(snapshot.map_err(|err| RpcError::new(RpcErrorCode::InvalidRequest, err))?); + } + Ok(()) +} + +fn snapshot_result(mut interactions: Vec) -> Value { + let mut complete = interactions.len() <= PENDING_LIMIT; + interactions.sort_by_key(|row| { + ( + row["createdAtMs"].as_i64().unwrap_or(0), + row["requestId"].as_str().unwrap_or("").to_string(), ) - })?; - let interactions = session - .permission_manager - .pending_ids() - .await + }); + interactions.truncate(PENDING_LIMIT); + // Reserve the response wrapper and JSON-RPC envelope as well as row commas. + let mut bytes = 128; + let mut bounded = Vec::new(); + for mut row in interactions { + let (args, truncated) = super::session::mobile_tool_data(&row["toolArgs"]); + // The mobile permission card consumes a field map. Preserve other + // valid JSON inputs under `input` rather than emitting an invalid map. + row["toolArgs"] = if args.is_object() { + args + } else { + json!({"input": args}) + }; + row["toolArgsTruncated"] = json!(truncated || row["toolArgsTruncated"] == true); + bytes += row.to_string().len() + 1; + if bytes > 512 * 1024 { + complete = false; + break; + } + bounded.push(row); + } + json!({"interactions": bounded, "complete": complete}) +} + +fn read_session_names( + conn: &rusqlite::Connection, + ids: &[String], +) -> Result, String> { + use rusqlite::OptionalExtension; + let mut statement = conn + .prepare_cached( + "SELECT substr(title, 1, 512) FROM orgtrack_core_sessions WHERE session_id = ?1", + ) + .map_err(|err| err.to_string())?; + let mut names = std::collections::HashMap::new(); + for id in ids.iter().take(PENDING_LIMIT) { + let title: Option = statement + .query_row([id], |row| row.get(0)) + .optional() + .map_err(|err| err.to_string())?; + if let Some(title) = title.filter(|title| !title.trim().is_empty()) { + names.insert(id.clone(), title); + } + } + Ok(names) +} + +async fn enriched_snapshot_result(interactions: Vec) -> Value { + let mut result = snapshot_result(interactions); + let complete = result["complete"] == true; + let rows = result["interactions"] + .as_array_mut() + .expect("snapshot row array"); + let ids = rows + .iter() + .filter_map(|row| row["sessionId"].as_str().map(str::to_string)) + .collect::>() .into_iter() - .map(|request_id| { - json!({ - "kind": "permission", - "origin": "rust_agent", - "sessionId": session_id, - "requestId": request_id, - }) + .collect::>(); + if !ids.is_empty() { + // Optional labels never gate the authoritative pending decisions. One + // bounded indexed lookup batch; no history scan or render-thread I/O. + let names = tokio::task::spawn_blocking(move || { + let conn = database::db::get_connection().map_err(|err| err.to_string())?; + read_session_names(&conn, &ids) }) + .await; + if let Ok(Ok(names)) = names { + for row in rows.iter_mut() { + if let Some(name) = row["sessionId"].as_str().and_then(|id| names.get(id)) { + row["sessionName"] = json!(name); + } + } + } + } + let mut enriched = snapshot_result(std::mem::take(rows)); + enriched["complete"] = json!(complete && enriched["complete"] == true); + enriched +} + +pub async fn pending_all(_params: &Value) -> Result { + let handle = crate::api::get_app_handle() + .ok_or_else(|| RpcError::new(RpcErrorCode::InvalidRequest, "desktop agent not ready"))?; + let state = handle.state::(); + let sessions = state + .sessions + .lock() + .await + .values() + .cloned() .collect::>(); - Ok(json!({ "interactions": interactions })) + let mut interactions = Vec::new(); + for session in sessions { + let remaining = (PENDING_LIMIT + 1).saturating_sub(interactions.len()); + if remaining == 0 { + break; + } + interactions.extend(session.permission_manager.pending_snapshot(remaining).await); + } + append_cli_pending(&mut interactions, None)?; + Ok(enriched_snapshot_result(interactions).await) } #[cfg(test)] mod tests { use super::*; + #[test] + fn pending_session_labels_use_canonical_titles_without_loading_roster() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE orgtrack_core_sessions (session_id TEXT PRIMARY KEY, title TEXT); INSERT INTO orgtrack_core_sessions VALUES ('old-session', 'History beyond the first page')").unwrap(); + let names = + read_session_names(&conn, &["old-session".into(), "missing-session".into()]).unwrap(); + assert_eq!( + names.get("old-session").unwrap(), + "History beyond the first page" + ); + assert!(!names.contains_key("missing-session")); + let row = snapshot_result(vec![ + json!({"toolArgs": ["one", "two"], "toolArgsTruncated": true}), + ]); + assert!(row["interactions"][0]["toolArgs"].is_object()); + assert_eq!(row["interactions"][0]["toolArgsTruncated"], true); + } + + #[test] + fn inbox_projection_is_bounded_and_never_claims_truncated_count_is_complete() { + let rows = (0..PENDING_LIMIT + 1) + .map(|index| { + json!({ + "requestId": format!("p-{index}"), "createdAtMs": index, + "toolArgs": {"description": "x".repeat(10000)}, + }) + }) + .collect(); + let result = snapshot_result(rows); + assert_eq!(result["complete"], false); + assert!(result.to_string().len() < 513 * 1024); + assert!(result["interactions"].as_array().unwrap().len() < PENDING_LIMIT); + assert_eq!(result["interactions"][0]["toolArgsTruncated"], true); + assert_eq!( + snapshot_result(vec![]), + json!({"interactions": [], "complete": true}) + ); + let at_limit = (0..PENDING_LIMIT) + .map(|index| json!({"requestId": index.to_string()})) + .collect::>(); + assert_eq!(snapshot_result(at_limit.clone())["complete"], true); + let mut over_limit = at_limit; + over_limit.push(json!({"requestId": "sentinel"})); + assert_eq!(snapshot_result(over_limit)["complete"], false); + } + #[test] fn parse_respond_permission_params_requires_session_and_request_ids() { let err = parse_respond_permission_params(&json!({ diff --git a/src-tauri/src/api/mobile_bridge/adapters/mod.rs b/src-tauri/src/api/mobile_bridge/adapters/mod.rs index 50968a0fb3..19d830b346 100644 --- a/src-tauri/src/api/mobile_bridge/adapters/mod.rs +++ b/src-tauri/src/api/mobile_bridge/adapters/mod.rs @@ -1,5 +1,9 @@ mod external_send; +pub mod change_review; +mod change_review_stats; +mod workspace_changes; pub mod file_navigation; +pub mod images; pub mod interaction; pub mod model; pub mod session; diff --git a/src-tauri/src/api/mobile_bridge/adapters/session.rs b/src-tauri/src/api/mobile_bridge/adapters/session.rs index 9e00923822..b678dc5f47 100644 --- a/src-tauri/src/api/mobile_bridge/adapters/session.rs +++ b/src-tauri/src/api/mobile_bridge/adapters/session.rs @@ -163,14 +163,28 @@ pub fn mobile_session_name(name: &str, display_label: Option<&str>) -> String { .to_string() } -fn is_mobile_list_category(category: SessionCategory) -> bool { - matches!( - category, - SessionCategory::Agent | SessionCategory::Os | SessionCategory::Cli - ) +/// Only the display resolver's inputs cross this boundary, never the full +/// aggregate (which also contains account, storage and provider provenance). +fn mobile_session_display(record: &SessionAggregateRecord) -> Value { + let mut display = Map::new(); + for (key, value) in [ + ("cliAgentType", record.cli_agent_type.as_deref()), + ("agentOrgId", record.agent_org_id.as_deref()), + ("agentDefinitionId", record.agent_definition_id.as_deref()), + ("agentIconId", record.agent_icon_id.as_deref()), + ("model", record.model.as_deref()), + ( + "externalHistorySource", + record.external_history_source.as_deref(), + ), + ] { + if let Some(value) = value.filter(|value| !value.is_empty() && value.len() <= 512) { + display.insert(key.to_string(), json!(value)); + } + } + Value::Object(display) } -/// Keep directory metadata on the wire; do not reconstruct it from a title. fn mobile_directory_session_row(record: &SessionAggregateRecord, send_capability: &str) -> Value { let updated_at_ms = chrono::DateTime::parse_from_rfc3339(&record.updated_at) .ok() @@ -178,14 +192,25 @@ fn mobile_directory_session_row(record: &SessionAggregateRecord, send_capability json!({ "id": record.session_id, "name": mobile_session_name(&record.name, record.display_label.as_deref()), - "status": map_session_status_to_mobile(&record.status), "repoPath": record.repo_path, "repoName": record.repo_name, "updatedAtMs": updated_at_ms, + // Retained for old clients; new clients read the lossless lifecycle. + "status": map_session_status_to_mobile(&record.status), + "lifecycleStatus": record.status, + "mergeStatus": record.merge_status, + "display": mobile_session_display(record), "sendCapability": send_capability, }) } +fn is_mobile_list_category(category: SessionCategory) -> bool { + matches!( + category, + SessionCategory::Agent | SessionCategory::Os | SessionCategory::Cli + ) +} + fn session_list_from_sidebar_snapshot( snapshot: Vec, status_filter: &str, @@ -1191,7 +1216,7 @@ fn compact_mobile_tool_json( } } -fn mobile_tool_data(value: &Value) -> (Value, bool) { +pub(super) fn mobile_tool_data(value: &Value) -> (Value, bool) { let mut truncated = false; let regular_limits = MobileToolDataLimits { max_depth: MAX_MOBILE_TOOL_DATA_DEPTH, @@ -1436,6 +1461,7 @@ fn mobile_event_from_session(event: &SessionEvent) -> Option<(Value, bool)> { "displayText": display_text, "createdAt": event.created_at, }); + mobile_event["imageCount"] = json!(super::images::image_count(&event.result)); let extracted = event .extracted .as_ref() @@ -1528,6 +1554,9 @@ fn mobile_event_from_wire(event: &Value) -> Option<(Value, bool)> { } else { false }; + mobile_event["imageCount"] = json!(super::images::image_count( + event.get("result").unwrap_or(&Value::Null) + )); let turn_intent_id = wire_string(event, "turnIntentId", "turn_intent_id"); let turn_intent_id = if turn_intent_id.is_empty() { event @@ -1708,6 +1737,54 @@ fn build_subscription_snapshot( value } +/// Resolve the authoritative owner and return its first body in one round trip. +/// The lease can be closed before its canonical ID is known to the phone. +pub async fn session_open(conn_id: u64, params: &Value) -> Result { + session_open_with_loader(conn_id, params, |id, latest| async move { + load_subscription_history(&id, latest).await + }) + .await +} + +async fn session_open_with_loader( + conn_id: u64, + params: &Value, + load: F, +) -> Result +where + F: FnOnce(String, bool) -> Fut, + Fut: std::future::Future>, +{ + let token = params + .get("subscriptionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty() && id.len() <= 128) + .ok_or_else(|| RpcError::invalid_params("subscriptionId is required"))?; + let identity = super::session_identity::resolve(params).await?; + let session_id = identity["sessionId"] + .as_str() + .ok_or_else(|| RpcError::invalid_params("invalid session identity"))?; + if !fanout::open_session(conn_id, token, session_id, MAX_CONCURRENT_SUBSCRIPTIONS) { + return Err(RpcError::new( + RpcErrorCode::InvalidRequest, + "connection not registered", + )); + } + let latest_only = params.get("latestOnly").and_then(Value::as_bool) == Some(true); + let history = match load(session_id.to_owned(), latest_only).await { + Ok(history) => history, + Err(error) => { + fanout::close_open_session(conn_id, token); + return Err(error); + } + }; + let mut response = subscription_history_response(session_id, history.0, history.1); + response["managed"] = identity["managed"].clone(); + response["subscriptionId"] = Value::String(token.to_owned()); + response["subscribed"] = Value::Bool(true); + Ok(response) +} + /// Subscribe a mobile connection to session event fanout and return one /// authoritative, bounded history snapshot for the selected session. pub async fn session_subscribe(conn_id: u64, params: &Value) -> Result { @@ -1733,13 +1810,74 @@ pub async fn session_subscribe(conn_id: u64, params: &Value) -> Result loaded, Err(err) => { fanout::unsubscribe_session(conn_id, session_id); return Err(err); } }; + let mut response = subscription_history_response(session_id, history.0, history.1); + response["subscribed"] = Value::Bool(true); + Ok(response) +} + +/// Read the full directory after a fast subscription response, without changing +/// subscription state or occupying the mutation lane. +pub async fn session_history(params: &Value) -> Result { + let session_id = params + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| RpcError::invalid_params("sessionId is required"))?; + let history = load_mobile_initial_history(session_id).await?; + Ok(subscription_history_response(session_id, history, false)) +} + +async fn load_subscription_history( + session_id: &str, + latest_only: bool, +) -> Result<(MobileHistoryWindow, bool), RpcError> { + let history_id = mobile_history_session_id(session_id); + if latest_only && history_id.starts_with(orgtrack_core::sources::codex::SESSION_PREFIX) { + // This bounded tail reader does not build the full turn catalog. Empty + // or exceptionally large latest turns retain the existing full reader. + if let Ok(window) = + crate::orgtrack::history_commands::codex_app_mobile_tail_window(history_id).await + { + return mobile_codex_preview(session_id, window) + .await + .map(|history| (history, true)); + } + } + load_mobile_initial_history(session_id) + .await + .map(|history| (history, false)) +} + +async fn mobile_codex_preview( + session_id: &str, + window: orgtrack_core::sources::codex::app::CodexAppInitialWindow, +) -> Result { + let rounds = mobile_rounds_from_projected(&window.turns); + let latest_round_id = rounds.last().map(|round| round.id.clone()); + let chunks = latest_round_chunks(window.chunks, latest_round_id.as_deref()); + let loaded = events_from_chunks(chunks, session_id.to_string()).await?; + Ok(MobileHistoryWindow { + rounds, + rounds_complete: false, + latest_round_id, + events: loaded.events, + events_truncated: loaded.truncated, + }) +} + +fn subscription_history_response( + session_id: &str, + history: MobileHistoryWindow, + history_deferred: bool, +) -> Value { let snapshot = build_subscription_snapshot( session_id, history.latest_round_id.as_deref(), @@ -1748,15 +1886,15 @@ pub async fn session_subscribe(conn_id: u64, params: &Value) -> Result Result { /// Unsubscribe from a session fanout stream. pub fn session_unsubscribe(conn_id: u64, params: &Value) -> Result { + if let Some(token) = params.get("subscriptionId") { + let token = token + .as_str() + .filter(|id| !id.is_empty() && id.len() <= 128) + .ok_or_else(|| RpcError::invalid_params("invalid subscriptionId"))?; + fanout::close_open_session(conn_id, token); + return Ok(json!({ "unsubscribed": true })); + } + let session_id = params .get("sessionId") .and_then(|value| value.as_str()) @@ -1802,6 +1949,125 @@ pub fn session_unsubscribe(conn_id: u64, params: &Value) -> Result = loaded + .events + .iter() + .filter_map(mobile_event_from_session) + .map(|(event, _)| event["imageCount"].as_u64().unwrap_or(0)) + .collect(); + eprintln!("Authoritative round image counts: {counts:?}"); + assert!(counts.iter().any(|count| *count > 0)); + } + #[test] fn mobile_tool_projection_keeps_structured_context_with_hard_limits() { let event = json!({ diff --git a/src-tauri/src/api/mobile_bridge/adapters/workspace_changes.rs b/src-tauri/src/api/mobile_bridge/adapters/workspace_changes.rs new file mode 100644 index 0000000000..d00a876358 --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/adapters/workspace_changes.rs @@ -0,0 +1,229 @@ +//! On-demand bounded workspace review. No writes, external diff drivers or shell. +use crate::api::mobile_bridge::rpc::RpcError; +use serde_json::{json, Value}; +use std::{path::Path, process::Stdio, time::Duration}; +use tokio::io::AsyncReadExt; + +const BUDGET: u64 = 512 * 1024; + +async fn git(root: &Path, args: &[&str]) -> Result { + let result = tokio::time::timeout(Duration::from_secs(5), async { + let mut child = tokio::process::Command::new("git") + .arg("--literal-pathspecs") + .args(args) + .current_dir(root) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|_| RpcError::invalid_params("git unavailable"))?; + let mut bytes = Vec::new(); + child + .stdout + .take() + .ok_or_else(|| RpcError::invalid_params("git output unavailable"))? + .take(BUDGET + 1) + .read_to_end(&mut bytes) + .await + .map_err(|_| RpcError::invalid_params("git read failed"))?; + if bytes.len() > BUDGET as usize { + return Err(RpcError::invalid_params( + "workspace change exceeds preview budget", + )); + } + if !child + .wait() + .await + .map_err(|_| RpcError::invalid_params("git wait failed"))? + .success() + { + return Err(RpcError::invalid_params("workspace git query failed")); + } + String::from_utf8(bytes).map_err(|_| RpcError::invalid_params("binary content unavailable")) + }) + .await; + result.map_err(|_| RpcError::invalid_params("workspace query timed out"))? +} + +pub(super) async fn read(session_id: &str, selected: Option<&str>) -> Result { + let id = session_id.to_owned(); + let root = + tokio::task::spawn_blocking(move || super::file_navigation::session_workspace_root(&id)) + .await + .map_err(|_| RpcError::invalid_params("workspace lookup failed"))??; + let root = tokio::fs::canonicalize(root) + .await + .map_err(|_| RpcError::invalid_params("workspace unavailable"))?; + read_at(&root, selected).await +} + +async fn read_at(root: &Path, selected: Option<&str>) -> Result { + let status = git( + root, + &["status", "--porcelain=v1", "-z", "--untracked-files=all"], + ) + .await?; + let mut entries = status.split('\0').filter(|entry| !entry.is_empty()); + let mut files = Vec::new(); + while let Some(entry) = entries.next() { + if entry.len() < 4 || !entry.is_char_boundary(3) { + return Err(RpcError::invalid_params("invalid git status")); + } + let path = &entry[3..]; + let renamed = entry[..2].contains('R') || entry[..2].contains('C'); + let original = if renamed { + entries + .next() + .ok_or_else(|| RpcError::invalid_params("invalid rename"))? + } else { + path + }; + if files.len() >= 500 { + return Err(RpcError::invalid_params("too many changed files")); + } + let mut file = json!({"path":path, "additions":null, "deletions":null, "patches":[], "before":null, "after":null, "availability":"current_workspace"}); + if selected == Some(path) { + let candidate = root.join(path); + if let Ok(canonical) = tokio::fs::canonicalize(&candidate).await { + if !canonical.starts_with(root) { + return Err(RpcError::invalid_params("file outside workspace")); + } + let handle = tokio::fs::File::open(canonical) + .await + .map_err(|_| RpcError::invalid_params("file unavailable"))?; + if handle + .metadata() + .await + .map_err(|_| RpcError::invalid_params("file unavailable"))? + .is_file() + { + let mut bytes = Vec::new(); + handle + .take(BUDGET + 1) + .read_to_end(&mut bytes) + .await + .map_err(|_| RpcError::invalid_params("file read failed"))?; + if bytes.len() <= BUDGET as usize && !bytes.contains(&0) { + if let Ok(text) = String::from_utf8(bytes) { + file["after"] = json!(text); + } + } + } + } + if &entry[..2] != "??" { + let spec = format!("HEAD:{original}"); + if let Ok(before) = git(root, &["show", &spec]).await { + if !before.contains('\0') { + file["before"] = json!(before); + } + } + if let Ok(patch) = git( + root, + &[ + "diff", + "--no-ext-diff", + "--no-textconv", + "HEAD", + "--", + original, + path, + ], + ) + .await + { + if let Some((added, removed)) = super::change_review_stats::numstat(&patch) { + file["additions"] = json!(added); + file["deletions"] = json!(removed); + } + file["patches"] = json!([patch]); + } + } else if let Some(after) = file["after"].as_str() { + let added = after.lines().count(); + file["additions"] = json!(added); + file["deletions"] = json!(0); + file["before"] = json!(""); + } + } + files.push(file); + } + Ok(json!({"files":files,"complete":true})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn reads_current_and_head_without_writes_and_rejects_external_symlink() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + git(&root, &["init"]).await.unwrap(); + std::fs::write(root.join("a file.ts"), "old\n").unwrap(); + git(&root, &["add", "a file.ts"]).await.unwrap(); + git( + &root, + &[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "initial", + ], + ) + .await + .unwrap(); + std::fs::write(root.join("a file.ts"), "new\n").unwrap(); + let before_status = git(&root, &["status", "--porcelain=v1", "-z"]) + .await + .unwrap(); + let manifest = read_at(&root, None).await.unwrap(); + assert!(manifest["files"][0]["after"].is_null()); + let detail = read_at(&root, Some("a file.ts")).await.unwrap(); + assert_eq!(detail["files"][0]["before"], "old\n"); + assert_eq!(detail["files"][0]["after"], "new\n"); + assert_eq!(detail["files"][0]["additions"], 1); + assert_eq!(detail["files"][0]["deletions"], 1); + // The authoritative git boundary must distinguish a binary edit from + // a zero-line text change, and preserve deleted/new file contents. + std::fs::write(root.join("a file.ts"), b"binary\0payload").unwrap(); + let binary = read_at(&root, Some("a file.ts")).await.unwrap(); + assert!(binary["files"][0]["additions"].is_null()); + assert!(binary["files"][0]["after"].is_null()); + std::fs::remove_file(root.join("a file.ts")).unwrap(); + let deleted = read_at(&root, Some("a file.ts")).await.unwrap(); + assert_eq!(deleted["files"][0]["before"], "old\n"); + assert_eq!(deleted["files"][0]["deletions"], 1); + assert!(deleted["files"][0]["after"].is_null()); + std::fs::write(root.join("new.ts"), "new file\n").unwrap(); + let new = read_at(&root, Some("new.ts")).await.unwrap(); + let new = new["files"] + .as_array() + .unwrap() + .iter() + .find(|f| f["path"] == "new.ts") + .unwrap(); + assert_eq!(new["before"], ""); + assert_eq!(new["after"], "new file\n"); + assert_eq!(new["additions"], 1); + std::fs::remove_file(root.join("new.ts")).unwrap(); + std::fs::write(root.join("a file.ts"), "new\n").unwrap(); + assert_eq!( + before_status, + git(&root, &["status", "--porcelain=v1", "-z"]) + .await + .unwrap() + ); + let unknown = read_at(&root, Some("../outside")).await.unwrap(); + assert!(unknown["files"][0]["after"].is_null()); + #[cfg(unix)] + { + let outside = tempfile::NamedTempFile::new().unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("external")).unwrap(); + assert!(read_at(&root, Some("external")).await.is_err()); + } + } +} diff --git a/src-tauri/src/api/mobile_bridge/fanout.rs b/src-tauri/src/api/mobile_bridge/fanout.rs index 688d6fd6d6..4bf235eb15 100644 --- a/src-tauri/src/api/mobile_bridge/fanout.rs +++ b/src-tauri/src/api/mobile_bridge/fanout.rs @@ -12,10 +12,16 @@ static NEXT_CONN_ID: AtomicU64 = AtomicU64::new(1); struct MobileConnection { sender: mpsc::Sender, subscribed_sessions: HashSet, + opened_session: Option<(String, String)>, } static CONNECTIONS: OnceLock>> = OnceLock::new(); +// Registry tests and transport tests share the real fanout registry. Serialize +// only those tests so clear/broadcast assertions cannot disturb live fixtures. +#[cfg(test)] +pub(super) static TEST_REGISTRY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + fn connections() -> &'static RwLock> { CONNECTIONS.get_or_init(|| RwLock::new(HashMap::new())) } @@ -29,6 +35,7 @@ pub fn register_connection(sender: mpsc::Sender) -> u64 { MobileConnection { sender, subscribed_sessions: HashSet::new(), + opened_session: None, }, ); } @@ -65,12 +72,45 @@ pub fn unsubscribe_session(conn_id: u64, session_id: &str) { } } +/// The foreground chat owns one replaceable subscription lease. A late close +/// from an older opening must not unsubscribe a newer opening of the same ID. +pub fn open_session(conn_id: u64, token: &str, session_id: &str, limit: usize) -> bool { + if let Ok(mut map) = connections().write() { + if let Some(conn) = map.get_mut(&conn_id) { + if conn.subscribed_sessions.len() >= limit { + return false; + } + conn.opened_session = Some((token.to_owned(), session_id.to_owned())); + return true; + } + } + false +} + +pub fn close_open_session(conn_id: u64, token: &str) { + if let Ok(mut map) = connections().write() { + if let Some(conn) = map.get_mut(&conn_id) { + if conn + .opened_session + .as_ref() + .is_some_and(|(current, _)| current == token) + { + conn.opened_session = None; + } + } + } +} + /// Number of sessions a connection is subscribed to. pub fn subscription_count(conn_id: u64) -> usize { connections() .read() .ok() - .and_then(|map| map.get(&conn_id).map(|conn| conn.subscribed_sessions.len())) + .and_then(|map| { + map.get(&conn_id).map(|conn| { + conn.subscribed_sessions.len() + usize::from(conn.opened_session.is_some()) + }) + }) .unwrap_or(0) } @@ -80,8 +120,13 @@ pub fn is_subscribed(conn_id: u64, session_id: &str) -> bool { .read() .ok() .and_then(|map| { - map.get(&conn_id) - .map(|conn| conn.subscribed_sessions.contains(session_id)) + map.get(&conn_id).map(|conn| { + conn.subscribed_sessions.contains(session_id) + || conn + .opened_session + .as_ref() + .is_some_and(|(_, id)| id == session_id) + }) }) .unwrap_or(false) } @@ -93,7 +138,13 @@ pub fn fanout_to_session(session_id: &str, message: &str) { .ok() .map(|map| { map.values() - .filter(|conn| conn.subscribed_sessions.contains(session_id)) + .filter(|conn| { + conn.subscribed_sessions.contains(session_id) + || conn + .opened_session + .as_ref() + .is_some_and(|(_, id)| id == session_id) + }) .map(|conn| conn.sender.clone()) .collect() }) @@ -138,6 +189,12 @@ pub fn on_bus_message(message: &str) { } }); fanout_to_session(&session_id, ¬ification.to_string()); + if matches!( + envelope.get("type").and_then(Value::as_str), + Some("permission:request" | "permission:pending_changed") + ) { + fanout_all(&serde_json::json!({"jsonrpc":"2.0", "method":"interaction/pending_changed", "params":{"sessionId":session_id}}).to_string()); + } } /// Hook point: forward EventStore snapshot envelopes to mobile `orgii/snapshot` notifications. @@ -163,13 +220,10 @@ pub fn on_snapshot_envelope(envelope: &Value) { #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, MutexGuard}; use tokio::sync::mpsc::error::TryRecvError; - static TEST_REGISTRY_LOCK: Mutex<()> = Mutex::new(()); - - fn isolated_registry() -> MutexGuard<'static, ()> { - let guard = TEST_REGISTRY_LOCK.lock().expect("test registry lock"); + fn isolated_registry() -> tokio::sync::MutexGuard<'static, ()> { + let guard = TEST_REGISTRY_LOCK.blocking_lock(); connections().write().expect("connections lock").clear(); guard } @@ -180,6 +234,30 @@ mod tests { (conn_id, rx) } + #[test] + fn opening_lease_replaces_previous_and_ignores_late_or_foreign_close() { + let _guard = isolated_registry(); + let (conn, _rx) = fresh_conn(); + let (other, _other_rx) = fresh_conn(); + assert!(open_session(conn, "a", "same", 4)); + assert!(open_session(conn, "b", "same", 4)); + close_open_session(conn, "a"); + close_open_session(other, "b"); + assert!(is_subscribed(conn, "same")); + assert_eq!(subscription_count(conn), 1); + assert!(open_session(conn, "c", "next", 4)); + assert!(!is_subscribed(conn, "same")); + assert!(is_subscribed(conn, "next")); + close_open_session(conn, "c"); + assert_eq!(subscription_count(conn), 0); + for id in ["1", "2", "3", "4"] { + subscribe_session(conn, id); + } + assert!(!open_session(conn, "overflow", "extra", 4)); + unregister_connection(conn); + unregister_connection(other); + } + #[test] fn fanout_routes_only_to_subscribed_session() { let _registry = isolated_registry(); @@ -250,6 +328,23 @@ mod tests { unregister_connection(conn_id); } + #[test] + fn approval_invalidation_reaches_unopened_sessions_but_other_bus_events_do_not() { + let _registry = isolated_registry(); + let (_conn, mut rx) = fresh_conn(); + for event_type in ["permission:request", "permission:pending_changed"] { + on_bus_message( + &serde_json::json!({"type":event_type,"payload":{"sessionId":"unopened"}}) + .to_string(), + ); + let notification: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); + assert_eq!(notification["method"], "interaction/pending_changed"); + assert_eq!(notification["params"]["sessionId"], "unopened"); + } + on_bus_message(r#"{"type":"text:delta","payload":{"sessionId":"unopened"}}"#); + assert_eq!(rx.try_recv(), Err(TryRecvError::Empty)); + } + #[test] fn on_snapshot_envelope_fanouts_orgii_snapshot_method() { let _registry = isolated_registry(); diff --git a/src-tauri/src/api/mobile_bridge/mod.rs b/src-tauri/src/api/mobile_bridge/mod.rs index 0dac51dc0f..58ae8b5def 100644 --- a/src-tauri/src/api/mobile_bridge/mod.rs +++ b/src-tauri/src/api/mobile_bridge/mod.rs @@ -7,7 +7,9 @@ mod desktop_identity; pub mod fanout; pub mod org2_cloud_auth; pub mod relay; +mod request_scheduler; pub mod rpc; +pub mod read_state; pub mod ws_handler; use axum::routing::get; diff --git a/src-tauri/src/api/mobile_bridge/read_state.rs b/src-tauri/src/api/mobile_bridge/read_state.rs new file mode 100644 index 0000000000..e70591bb52 --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/read_state.rs @@ -0,0 +1,198 @@ +//! Request/ack bridge to Desktop's existing visited-session owner. No second +//! persistent store, roster scan, or polling task lives in the transport. +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, + time::Duration, +}; +use tauri::{Emitter, Manager}; +use tokio::sync::oneshot; + +use super::rpc::{RpcError, RpcErrorCode}; + +const MAX_IDS: usize = 200; +const MAX_PENDING: usize = 32; +const TIMEOUT: Duration = Duration::from_secs(5); + +struct Pending { + ids: Vec, + sender: oneshot::Sender>, +} +fn pending() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(Mutex::default) +} +struct RequestGuard(String); +impl Drop for RequestGuard { + fn drop(&mut self) { + pending().lock().unwrap().remove(&self.0); + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReadRequest { + request_id: String, + session_ids: Vec, + mark_visited: bool, + expires_at_ms: i64, +} + +fn parse_ids(params: &Value) -> Result, RpcError> { + let ids: Vec = + serde_json::from_value(params.get("sessionIds").cloned().unwrap_or(Value::Null)) + .map_err(|_| RpcError::invalid_params("sessionIds must be an array"))?; + if ids.is_empty() + || ids.len() > MAX_IDS + || ids.iter().any(|id| id.trim().is_empty() || id.len() > 1024) + { + return Err(RpcError::invalid_params( + "sessionIds must contain 1–200 nonempty IDs, at most 1024 bytes each", + )); + } + let mut unique = Vec::with_capacity(ids.len()); + for id in ids { + if !unique.contains(&id) { + unique.push(id); + } + } + Ok(unique) +} + +pub async fn request(params: &Value, mark_visited: bool) -> Result { + let ids = parse_ids(params)?; + let handle = crate::api::get_app_handle().ok_or_else(unavailable)?; + let window = handle.get_webview_window("main").ok_or_else(unavailable)?; + let request_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + { + let mut requests = pending().lock().unwrap(); + if requests.len() >= MAX_PENDING { + return Err(unavailable()); + } + requests.insert( + request_id.clone(), + Pending { + ids: ids.clone(), + sender, + }, + ); + } + // Drop removes the waiter on timeout, emission failure AND task cancellation. + let _guard = RequestGuard(request_id.clone()); + window + .emit( + "mobile-session-read-request", + ReadRequest { + request_id, + session_ids: ids, + mark_visited, + expires_at_ms: chrono::Utc::now().timestamp_millis() + TIMEOUT.as_millis() as i64, + }, + ) + .map_err(|_| unavailable())?; + let visited_ids = tokio::time::timeout(TIMEOUT, receiver) + .await + .map_err(|_| unavailable())? + .map_err(|_| unavailable())?; + Ok(json!({ "visitedIds": visited_ids })) +} + +fn unavailable() -> RpcError { + RpcError::new( + RpcErrorCode::InvalidRequest, + "desktop read-state owner is unavailable; retry after reconnecting", + ) +} + +fn complete(request_id: &str, visited_ids: Vec) -> Result<(), String> { + let mut requests = pending().lock().unwrap(); + let request = requests + .get(request_id) + .ok_or("read-state request expired")?; + if visited_ids.len() > request.ids.len() + || visited_ids.iter().any(|id| !request.ids.contains(id)) + { + return Err("read-state reply contains unrequested IDs".into()); + } + let request = requests + .remove(request_id) + .ok_or("read-state request expired")?; + let _ = request.sender.send(visited_ids); + Ok(()) +} + +#[tauri::command] +pub fn mobile_remote_reply_read_state( + window: tauri::WebviewWindow, + request_id: String, + visited_ids: Vec, +) -> Result<(), String> { + if window.label() != "main" { + return Err("only the desktop owner can reply".into()); + } + complete(&request_id, visited_ids) +} + +#[tauri::command] +pub fn mobile_remote_read_state_changed(window: tauri::WebviewWindow) -> Result<(), String> { + if window.label() != "main" { + return Err("only the desktop owner can publish".into()); + } + super::fanout::fanout_all( + &json!({ + "jsonrpc": "2.0", "method": "session/read_state_changed", "params": {} + }) + .to_string(), + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bounds_and_deduplicates_at_rpc_ingestion() { + assert_eq!( + parse_ids(&json!({"sessionIds":["a","a","b"]})).unwrap(), + vec!["a", "b"] + ); + for ids in [ + json!([]), + json!([""]), + json!([1]), + json!(["x".repeat(1025)]), + json!(vec!["a"; 201]), + ] { + assert!(parse_ids(&json!({"sessionIds":ids})).is_err()); + } + } + #[tokio::test] + async fn ack_is_scoped_and_waiter_is_released() { + let key = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + pending().lock().unwrap().insert( + key.clone(), + Pending { + ids: vec!["a".into()], + sender, + }, + ); + assert!(complete(&key, vec!["other".into()]).is_err()); + complete(&key, vec!["a".into()]).unwrap(); + assert_eq!(receiver.await.unwrap(), vec!["a"]); + assert!(complete(&key, vec![]).is_err()); + let (sender, _receiver) = oneshot::channel(); + pending().lock().unwrap().insert( + key.clone(), + Pending { + ids: vec![], + sender, + }, + ); + drop(RequestGuard(key.clone())); + assert!(!pending().lock().unwrap().contains_key(&key)); + } +} diff --git a/src-tauri/src/api/mobile_bridge/relay.rs b/src-tauri/src/api/mobile_bridge/relay.rs index 0596f80a8d..1d326fa236 100644 --- a/src-tauri/src/api/mobile_bridge/relay.rs +++ b/src-tauri/src/api/mobile_bridge/relay.rs @@ -25,9 +25,10 @@ use tokio_util::sync::CancellationToken; use super::auth::{self, MobileRemoteSettings}; use super::fanout; use super::org2_cloud_auth::{self, SESSION_EXPIRED_MESSAGE}; -use super::rpc::{self, MobileTier, RpcContext}; +use super::rpc::{MobileTier, RpcContext}; +use super::request_scheduler; -const ACTOR_QUEUE_CAPACITY: usize = 32; +const ACTOR_QUEUE_CAPACITY: usize = request_scheduler::REQUEST_QUEUE_CAPACITY; const RELAY_OUTBOUND_CAPACITY: usize = 256; const MAX_RELAY_FRAME_BYTES: usize = 1024 * 1024; const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); @@ -432,21 +433,48 @@ async fn handle_relay_frame( async fn run_mobile_actor( connection_id: String, tier: PermissionTier, - mut requests: mpsc::Receiver, + requests: mpsc::Receiver, outbound: mpsc::Sender, ) { + run_mobile_actor_with_scheduler( + connection_id, + tier, + requests, + outbound, + request_scheduler::run, + ) + .await; +} + +async fn run_mobile_actor_with_scheduler( + connection_id: String, + tier: PermissionTier, + requests: mpsc::Receiver, + outbound: mpsc::Sender, + run_scheduler: Run, +) where + Run: FnOnce(RpcContext, mpsc::Receiver, mpsc::Sender) -> RunFuture, + RunFuture: std::future::Future + Send + 'static, +{ let (fanout_tx, mut fanout_rx) = mpsc::channel::(64); let registration = FanoutRegistration(fanout::register_connection(fanout_tx)); - let mut context = relay_rpc_context(registration.0, tier); + let context = relay_rpc_context(registration.0, tier); + let (responses_tx, mut responses_rx) = mpsc::channel(ACTOR_QUEUE_CAPACITY); + // This task belongs to the phone actor, including when the relay drops or + // replaces it. Dropping a plain JoinHandle would leave its RPC work alive. + let _scheduler = ActorScheduler(tokio::spawn(run_scheduler( + context, + requests, + responses_tx, + ))); loop { tokio::select! { - request = requests.recv() => { - let Some(request) = request else { break; }; - if let Some(response) = rpc::dispatch(&mut context, &request).await { - if send_desktop_frame(&outbound, &connection_id, response).await.is_err() { - break; - } + _ = outbound.closed() => break, + response = responses_rx.recv() => { + let Some(response) = response else { break; }; + if send_desktop_frame(&outbound, &connection_id, response).await.is_err() { + break; } } notification = fanout_rx.recv() => { @@ -460,6 +488,14 @@ async fn run_mobile_actor( } } +struct ActorScheduler(JoinHandle<()>); + +impl Drop for ActorScheduler { + fn drop(&mut self) { + self.0.abort(); + } +} + struct FanoutRegistration(u64); impl Drop for FanoutRegistration { @@ -801,4 +837,184 @@ mod tests { assert!(read_only.initialized); assert_eq!(read_only.tier, MobileTier::ReadOnly); } + + struct NotifyTaskStopped(mpsc::Sender<()>); + + impl Drop for NotifyTaskStopped { + fn drop(&mut self) { + let _ = self.0.try_send(()); + } + } + + struct StalledActor { + task: JoinHandle<()>, + requests: mpsc::Sender, + outbound: mpsc::Receiver, + conn_id: u64, + release: Arc, + stopped: mpsc::Receiver<()>, + } + + async fn stalled_actor() -> StalledActor { + let (requests, request_rx) = mpsc::channel(ACTOR_QUEUE_CAPACITY); + let (outbound_tx, outbound) = mpsc::channel(8); + let (started_tx, mut started_rx) = mpsc::channel(1); + let (stopped_tx, stopped) = mpsc::channel(1); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let gate = release.clone(); + let task = tokio::spawn(run_mobile_actor_with_scheduler( + "phone-test".to_string(), + PermissionTier::Full, + request_rx, + outbound_tx, + move |ctx, requests, responses| { + request_scheduler::run_with_executor( + ctx, + requests, + responses, + move |ctx, request| { + let started = started_tx.clone(); + let stopped = stopped_tx.clone(); + let gate = gate.clone(); + async move { + let _stopped = NotifyTaskStopped(stopped); + started.send(ctx.conn_id).await.expect("test receiver alive"); + let _permit = gate.acquire().await.expect("test gate open"); + let response = serde_json::json!({ + "jsonrpc": "2.0", "id": request["id"], "result": "done" + }); + (ctx, Some(response)) + } + }, + ) + }, + )); + requests + .send(serde_json::json!({ "jsonrpc": "2.0", "id": 7, "method": "session/list" })) + .await + .expect("actor accepts request"); + let conn_id = tokio::time::timeout(Duration::from_secs(1), started_rx.recv()) + .await + .expect("RPC starts") + .expect("start signal"); + assert!(fanout::subscribe_session( + conn_id, + &format!("session-{conn_id}") + )); + StalledActor { + task, + requests, + outbound, + conn_id, + release, + stopped, + } + } + + async fn assert_actor_cleaned_up(conn_id: u64, stopped: &mut mpsc::Receiver<()>) { + tokio::time::timeout(Duration::from_secs(1), stopped.recv()) + .await + .expect("in-flight RPC is dropped") + .expect("RPC drop signal"); + assert_eq!(fanout::subscription_count(conn_id), 0); + assert!(!fanout::subscribe_session(conn_id, "must-not-survive")); + } + + #[tokio::test] + async fn relay_actor_forwards_notifications_while_rpc_is_stalled() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + let mut actor = stalled_actor().await; + let notification = serde_json::json!({ + "jsonrpc": "2.0", "method": "interaction/pending_changed", + "params": { "sessionId": format!("session-{}", actor.conn_id) } + }); + fanout::fanout_to_session( + &format!("session-{}", actor.conn_id), + ¬ification.to_string(), + ); + let message = tokio::time::timeout(Duration::from_secs(1), actor.outbound.recv()) + .await + .expect("notification must arrive before RPC finishes") + .expect("actor frame"); + let RelayWireFrame::DesktopFrame { + connection_id, + payload, + } = serde_json::from_str(&message.into_text().expect("text frame")).expect("relay frame") + else { + panic!("expected desktop frame") + }; + assert_eq!(connection_id, "phone-test"); + assert_eq!(payload, notification); + + actor.release.add_permits(1); + let response = tokio::time::timeout(Duration::from_secs(1), actor.outbound.recv()) + .await + .expect("RPC response after release") + .expect("response frame"); + let RelayWireFrame::DesktopFrame { payload, .. } = + serde_json::from_str(&response.into_text().expect("text response")).expect("relay frame") + else { + panic!("expected desktop response") + }; + assert_eq!(payload["id"], 7); + assert_eq!(payload["result"], "done"); + drop(actor.requests); + tokio::time::timeout(Duration::from_secs(1), actor.task) + .await + .expect("actor exits") + .expect("actor completed"); + assert_actor_cleaned_up(actor.conn_id, &mut actor.stopped).await; + } + + #[tokio::test] + async fn relay_disconnect_cancels_rpc_and_unregisters_actor() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + for _ in 0..3 { + let mut actor = stalled_actor().await; + let mut actors = HashMap::from([( + "phone-test".to_string(), + MobileActor { + requests: actor.requests, + task: actor.task, + }, + )]); + let (outbound, _outbound_rx) = mpsc::channel(1); + handle_relay_frame( + RelayWireFrame::MobileDisconnected { + connection_id: "phone-test".to_string(), + }, + "desktop-test", + &outbound, + &mut actors, + ) + .await; + assert!(actors.is_empty()); + assert_actor_cleaned_up(actor.conn_id, &mut actor.stopped).await; + } + } + + #[tokio::test] + async fn relay_outbound_close_cancels_rpc_and_unregisters_actor() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + let mut actor = stalled_actor().await; + drop(actor.outbound); + tokio::time::timeout(Duration::from_secs(1), actor.task) + .await + .expect("closed relay stops actor") + .expect("actor completed"); + assert_actor_cleaned_up(actor.conn_id, &mut actor.stopped).await; + assert!(actor.requests.is_closed()); + } + + #[tokio::test] + async fn relay_request_close_cancels_rpc_and_unregisters_actor() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + let mut actor = stalled_actor().await; + drop(actor.requests); + tokio::time::timeout(Duration::from_secs(1), actor.task) + .await + .expect("closed requests stop actor") + .expect("actor completed"); + assert_actor_cleaned_up(actor.conn_id, &mut actor.stopped).await; + } } diff --git a/src-tauri/src/api/mobile_bridge/request_scheduler.rs b/src-tauri/src/api/mobile_bridge/request_scheduler.rs new file mode 100644 index 0000000000..4d10e0e215 --- /dev/null +++ b/src-tauri/src/api/mobile_bridge/request_scheduler.rs @@ -0,0 +1,578 @@ +//! Bounded work owned by one authenticated mobile connection. +//! +//! Safe reads may overlap ordered mutations. Initialization is a barrier, and +//! every handler still passes through the dispatcher's permission checks. A +//! scheduler retains at most 32 pending requests (in addition to the bounded +//! transport input channel) and four reads plus one mutation in flight. +//! Search uses one of those read slots and retains only +//! its latest pending query. Transport notification forwarding is independent. +use std::{collections::VecDeque, future::Future}; + +use serde_json::{json, Value}; +use tokio::{sync::mpsc, task::JoinSet}; + +use super::rpc::{self, RpcContext}; + +pub const REQUEST_QUEUE_CAPACITY: usize = 32; +const MAX_CONCURRENT_READS: usize = 4; +const SERVER_BUSY: i32 = -32007; +const SEARCH_SUPERSEDED: i32 = -32008; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Lane { + Read, + Search, + Mutation, + Barrier, +} + +fn lane(request: &Value) -> Lane { + match request.get("method").and_then(Value::as_str) { + Some("initialize") => Lane::Barrier, + Some("session/list") + if request + .pointer("/params/query") + .and_then(Value::as_str) + .is_some_and(|query| !query.trim().is_empty()) => + { + Lane::Search + } + Some( + "session/list" | "session/resolve" | "session/round" | "session/changes" + | "session/image" | "session/config" | "models/list" | "session/read_state" + | "session/history", + ) => Lane::Read, + // Includes subscribe/unsubscribe, read-receipt writes, interactions, + // and unknown methods: additions must explicitly opt in to concurrency. + _ => Lane::Mutation, + } +} + +fn error(request: &Value, code: i32, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(Value::Null), + "error": { "code": code, "message": message } + }) +} + +pub async fn run(ctx: RpcContext, requests: mpsc::Receiver, responses: mpsc::Sender) { + run_with_executor(ctx, requests, responses, |mut ctx, request| async move { + let response = rpc::dispatch(&mut ctx, &request).await; + (ctx, response) + }) + .await; +} + +/// The injectable seam exercises production scheduling without real adapters. +pub(super) async fn run_with_executor( + mut ctx: RpcContext, + mut requests: mpsc::Receiver, + responses: mpsc::Sender, + execute: F, +) where + F: Fn(RpcContext, Value) -> Fut, + Fut: Future)> + Send + 'static, +{ + let mut pending = VecDeque::::new(); + let mut running = JoinSet::new(); + let mut active_reads = 0; + let mut active_search = false; + let mut active_mutation = false; + let mut active_barrier = false; + + loop { + // Closed input means disconnected, including buffered input. Never + // drain buffered mutations after their connection has disappeared. + if requests.is_closed() || responses.is_closed() { + break; + } + while !active_barrier { + let next = pending.iter().enumerate().find_map(|(index, request)| { + let kind = if ctx.initialized { + lane(request) + } else { + Lane::Barrier + }; + // No request may overtake an initialization barrier. + if kind == Lane::Barrier { + return Some((index, kind)); + } + let available = match kind { + Lane::Read => active_reads < MAX_CONCURRENT_READS, + Lane::Search => !active_search && active_reads < MAX_CONCURRENT_READS, + Lane::Mutation => !active_mutation, + Lane::Barrier => unreachable!(), + }; + available.then_some((index, kind)) + }); + let Some((index, kind)) = next else { break }; + if kind == Lane::Barrier && (index != 0 || !running.is_empty()) { + break; + } + // Recheck before admitting work after the receiver's last await. + if requests.is_closed() || responses.is_closed() { + break; + } + let request = pending.remove(index).expect("selected pending request"); + match kind { + Lane::Read => active_reads += 1, + Lane::Search => { + active_reads += 1; + active_search = true; + } + Lane::Mutation => active_mutation = true, + Lane::Barrier => active_barrier = true, + } + let future = execute(ctx.clone(), request); + running.spawn(async move { + let (context, response) = future.await; + (kind, context, response) + }); + } + + tokio::select! { + biased; + _ = responses.closed() => break, + result = running.join_next(), if !running.is_empty() => { + let Some(Ok((kind, context, response))) = result else { + // A panicked handler cannot leave a lane permanently busy + // or allow queued writes to execute with uncertain state. + break; + }; + match kind { + Lane::Read => active_reads -= 1, + Lane::Search => { + active_reads -= 1; + active_search = false; + } + Lane::Mutation => active_mutation = false, + Lane::Barrier => { + ctx = context; + active_barrier = false; + } + } + if let Some(response) = response { + // A stalled socket is terminal, not an unbounded secondary + // queue or a task that prevents disconnect detection. + if responses.try_send(response).is_err() { + break; + } + } + } + request = requests.recv() => { + let Some(request) = request else { break }; + if requests.is_closed() { + break; + } + if lane(&request) == Lane::Search { + if let Some(index) = pending.iter().position(|old| lane(old) == Lane::Search) { + let old = pending.remove(index).expect("pending search"); + if responses.try_send(error(&old, SEARCH_SUPERSEDED, "search superseded by a newer query")).is_err() { + break; + } + } + } + if pending.len() >= REQUEST_QUEUE_CAPACITY { + if responses.try_send(error(&request, SERVER_BUSY, "server busy; request queue is full")).is_err() { + break; + } + } else { + pending.push_back(request); + } + } + } + } + // JoinSet drop also aborts on cancellation of this owning scheduler. Abort + // only cancels async handlers: started spawn_blocking work may still finish, + // and already committed writes are not rolled back. + running.abort_all(); + while running.join_next().await.is_some() {} +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, + }; + + use tokio::{sync::oneshot, task::JoinHandle}; + + use super::*; + use crate::api::mobile_bridge::{auth::MobileRemoteSettings, rpc::MobileTier}; + + fn context(initialized: bool) -> RpcContext { + RpcContext { + conn_id: 17, + initialized, + tier: MobileTier::Full, + settings: MobileRemoteSettings { + enabled: true, + lan_token: "test".into(), + allow_lan_exposure: false, + }, + } + } + + fn request(id: u64, method: &str) -> Value { + json!({"jsonrpc":"2.0", "id":id, "method":method}) + } + + #[derive(Debug)] + enum Finish { + Success, + Error, + Panic, + } + + struct Call { + id: u64, + initialized: bool, + finish: oneshot::Sender, + } + + struct DropCount(Arc); + impl Drop for DropCount { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + struct Harness { + input: mpsc::Sender, + output: mpsc::Receiver, + calls: mpsc::Receiver, + dropped: Arc, + task: JoinHandle<()>, + } + + impl Harness { + fn start(initialized: bool, response_capacity: usize) -> Self { + let (input, requests) = mpsc::channel(REQUEST_QUEUE_CAPACITY); + let (responses, output) = mpsc::channel(response_capacity); + let (started, calls) = mpsc::channel(REQUEST_QUEUE_CAPACITY); + let dropped = Arc::new(AtomicUsize::new(0)); + let counter = dropped.clone(); + let task = tokio::spawn(run_with_executor( + context(initialized), + requests, + responses, + move |mut ctx, request| { + let started = started.clone(); + let dropped = counter.clone(); + async move { + let _guard = DropCount(dropped); + let id = request["id"].as_u64().unwrap(); + let (finish, done) = oneshot::channel(); + started + .send(Call { + id, + initialized: ctx.initialized, + finish, + }) + .await + .unwrap(); + let response = match done.await.expect("test releases call") { + Finish::Success => { + if request["method"] == "initialize" { + ctx.initialized = true; + } + json!({"jsonrpc":"2.0", "id":id, "result":{}}) + } + Finish::Error => error(&request, -32602, "test failure"), + Finish::Panic => panic!("injected handler panic"), + }; + (ctx, Some(response)) + } + }, + )); + Self { + input, + output, + calls, + dropped, + task, + } + } + + async fn send(&self, id: u64, method: &str) { + self.input.send(request(id, method)).await.unwrap(); + } + + async fn call(&mut self) -> Call { + tokio::time::timeout(Duration::from_secs(1), self.calls.recv()) + .await + .expect("call starts") + .expect("scheduler alive") + } + + async fn no_call(&mut self) { + assert!( + tokio::time::timeout(Duration::from_millis(10), self.calls.recv()) + .await + .is_err() + ); + } + + async fn response(&mut self) -> Value { + tokio::time::timeout(Duration::from_secs(1), self.output.recv()) + .await + .expect("response available") + .expect("scheduler alive") + } + + async fn stop(self) { + drop(self.input); + tokio::time::timeout(Duration::from_secs(1), self.task) + .await + .unwrap() + .unwrap(); + } + } + + #[tokio::test(start_paused = true)] + async fn reads_are_bounded_and_mutations_run_fifo_independently() { + let mut h = Harness::start(true, 32); + let mut reads = Vec::new(); + for id in 1..=MAX_CONCURRENT_READS as u64 { + h.send(id, "session/round").await; + reads.push(h.call().await); + } + h.send(10, "session/list").await; + h.send(11, "session/subscribe").await; + h.send(12, "session/unsubscribe").await; + let first_write = h.call().await; + assert_eq!(first_write.id, 11); + h.no_call().await; + first_write.finish.send(Finish::Success).unwrap(); + assert_eq!(h.response().await["id"], 11); + let second_write = h.call().await; + assert_eq!(second_write.id, 12); + second_write.finish.send(Finish::Error).unwrap(); + assert!(h.response().await.get("error").is_some()); + h.send(13, "session/send").await; + let third_write = h.call().await; + assert_eq!(third_write.id, 13); + third_write.finish.send(Finish::Success).unwrap(); + h.response().await; + reads.pop().unwrap().finish.send(Finish::Success).unwrap(); + h.response().await; + assert_eq!(h.call().await.id, 10); + h.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn latest_pending_search_replaces_old_query_and_preserves_ids() { + let mut h = Harness::start(true, 32); + h.input + .send(json!({"id":1,"method":"session/list","params":{"query":"first"}})) + .await + .unwrap(); + let first = h.call().await; + for id in 2..=3 { + h.input.send(json!({"id":id,"method":"session/list","params":{"query":format!("query-{id}")}})).await.unwrap(); + } + let superseded = h.response().await; + assert_eq!(superseded["id"], 2); + assert!(superseded["error"]["message"] + .as_str() + .unwrap() + .contains("superseded")); + h.no_call().await; + first.finish.send(Finish::Success).unwrap(); + assert_eq!(h.response().await["id"], 1); + let latest = h.call().await; + assert_eq!(latest.id, 3); + latest.finish.send(Finish::Success).unwrap(); + assert_eq!(h.response().await["id"], 3); + h.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn initialize_is_a_barrier_and_failed_initialization_does_not_unlock_reads() { + let mut h = Harness::start(false, 32); + h.send(1, "initialize").await; + let failed = h.call().await; + h.send(2, "session/list").await; + h.send(3, "initialize").await; + h.send(4, "session/round").await; + h.no_call().await; + failed.finish.send(Finish::Error).unwrap(); + h.response().await; + let rejected = h.call().await; + assert_eq!(rejected.id, 2); + assert!(!rejected.initialized); + rejected.finish.send(Finish::Error).unwrap(); + h.response().await; + let retry = h.call().await; + assert_eq!(retry.id, 3); + retry.finish.send(Finish::Success).unwrap(); + h.response().await; + let read = h.call().await; + assert_eq!(read.id, 4); + assert!(read.initialized); + h.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn repeated_initialize_waits_for_active_work_and_blocks_later_reads() { + let mut h = Harness::start(true, 32); + h.send(1, "session/round").await; + let read = h.call().await; + h.send(2, "session/send").await; + let write = h.call().await; + h.send(3, "initialize").await; + h.send(4, "models/list").await; + h.no_call().await; + read.finish.send(Finish::Success).unwrap(); + h.response().await; + h.no_call().await; + write.finish.send(Finish::Success).unwrap(); + h.response().await; + let initialize = h.call().await; + assert_eq!(initialize.id, 3); + h.no_call().await; + initialize.finish.send(Finish::Success).unwrap(); + h.response().await; + let later_read = h.call().await; + assert_eq!(later_read.id, 4); + h.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn real_dispatch_preserves_preinitialize_and_readonly_gates() { + for (initialized, method, expected) in [ + (false, "session/list", -32600), + (false, "session/send", -32600), + (true, "session/send", -32002), + (true, "session/cancel", -32002), + (true, "session/patch", -32002), + (true, "interaction/respond_permission", -32002), + ] { + let (input, requests) = mpsc::channel(1); + let (responses, mut output) = mpsc::channel(1); + let mut ctx = context(initialized); + ctx.tier = MobileTier::ReadOnly; + let task = tokio::spawn(run(ctx, requests, responses)); + input.send(request(1, method)).await.unwrap(); + let response = output.recv().await.unwrap(); + assert_eq!(response["error"]["code"], expected, "{method}"); + drop(input); + task.await.unwrap(); + } + } + + #[tokio::test(start_paused = true)] + async fn pending_queue_is_bounded_and_overload_is_explicit() { + let mut h = Harness::start(true, 32); + h.send(1, "session/send").await; + let _active = h.call().await; + for id in 2..=(REQUEST_QUEUE_CAPACITY as u64 + 2) { + h.send(id, "session/send").await; + } + let rejected = h.response().await; + assert_eq!(rejected["id"], REQUEST_QUEUE_CAPACITY as u64 + 2); + assert!(rejected["error"]["message"] + .as_str() + .unwrap() + .contains("queue is full")); + h.no_call().await; + h.stop().await; + } + + #[tokio::test(start_paused = true)] + async fn disconnect_drops_active_handlers_and_never_starts_queued_writes() { + let mut h = Harness::start(true, 32); + h.send(1, "session/send").await; + let _active = h.call().await; + h.send(2, "session/cancel").await; + h.no_call().await; + // Do not yield between buffering another write and disconnecting: + // both the internal queue and unread transport buffer must be dropped. + h.input.try_send(request(3, "session/patch")).unwrap(); + drop(h.input); + h.task.await.unwrap(); + assert_eq!(h.dropped.load(Ordering::SeqCst), 1); + assert!(h.calls.recv().await.is_none()); + assert!(h.output.recv().await.is_none()); + } + + #[tokio::test(start_paused = true)] + async fn handler_panic_cancels_peers_and_queued_work() { + let mut h = Harness::start(true, 32); + h.send(1, "session/list").await; + let panics = h.call().await; + h.send(2, "session/send").await; + let _write = h.call().await; + h.send(3, "session/cancel").await; + panics.finish.send(Finish::Panic).unwrap(); + h.task.await.unwrap(); + assert_eq!(h.dropped.load(Ordering::SeqCst), 2); + assert!(h.calls.recv().await.is_none()); + } + + #[tokio::test(start_paused = true)] + async fn closed_response_consumer_cancels_work_and_pending_mutations() { + let mut h = Harness::start(true, 32); + h.send(1, "session/send").await; + let _active = h.call().await; + h.send(2, "session/cancel").await; + h.no_call().await; + drop(h.output); + h.task.await.unwrap(); + assert!(h.calls.recv().await.is_none()); + assert_eq!(h.dropped.load(Ordering::SeqCst), 1); + assert!(h.input.is_closed()); + } + + #[tokio::test(start_paused = true)] + async fn aborting_scheduler_owner_drops_its_handlers() { + let mut h = Harness::start(true, 32); + h.send(1, "session/send").await; + let _active = h.call().await; + h.send(2, "session/cancel").await; + h.no_call().await; + h.task.abort(); + assert!(h.task.await.unwrap_err().is_cancelled()); + assert!(h.calls.recv().await.is_none()); + assert_eq!(h.dropped.load(Ordering::SeqCst), 1); + assert!(h.input.is_closed()); + } + + #[tokio::test(start_paused = true)] + async fn response_saturation_is_terminal_and_cleans_up() { + let mut h = Harness::start(true, 1); + h.send(1, "session/list").await; + let first = h.call().await; + h.send(2, "session/round").await; + let second = h.call().await; + first.finish.send(Finish::Success).unwrap(); + second.finish.send(Finish::Success).unwrap(); + h.task.await.unwrap(); + assert_eq!(h.dropped.load(Ordering::SeqCst), 2); + assert!(h.input.is_closed()); + } + + #[test] + fn new_methods_and_mark_visited_are_serialized_by_default() { + for method in [ + "session/mark_visited", + "session/subscribe", + "session/unsubscribe", + "future/method", + ] { + assert_eq!(lane(&request(1, method)), Lane::Mutation); + } + assert_eq!(lane(&request(1, "session/read_state")), Lane::Read); + for query in [Value::Null, json!(42), json!(" ")] { + assert_eq!( + lane(&json!({"method":"session/list","params":{"query":query}})), + Lane::Read + ); + } + } +} diff --git a/src-tauri/src/api/mobile_bridge/rpc.rs b/src-tauri/src/api/mobile_bridge/rpc.rs index 0d6d617d9f..3db45a0e83 100644 --- a/src-tauri/src/api/mobile_bridge/rpc.rs +++ b/src-tauri/src/api/mobile_bridge/rpc.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; -use super::adapters::{file_navigation, interaction, model, session}; +use super::adapters::{file_navigation, images, interaction, model, session}; use super::auth::MobileRemoteSettings; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -69,6 +69,7 @@ pub enum MobileTier { ReadOnly, } +#[derive(Clone)] pub struct RpcContext { pub conn_id: u64, pub initialized: bool, @@ -104,6 +105,10 @@ async fn dispatch_method( ) -> Result { match method { "initialize" => handle_initialize(ctx, params).await, + "session/open" => { + require_initialized(ctx)?; + session::session_open(ctx.conn_id, params).await + } "session/resolve" => { require_initialized(ctx)?; super::adapters::session_identity::resolve(params).await @@ -112,6 +117,15 @@ async fn dispatch_method( require_initialized(ctx)?; session::session_list(params).await } + "session/changes" => { + require_initialized(ctx)?; + super::adapters::change_review::historical_changes(params).await + } + "session/read_state" | "session/mark_visited" => { + require_initialized(ctx)?; + // A personal read receipt does not execute an agent operation. + super::read_state::request(params, method == "session/mark_visited").await + } "session/subscribe" => { require_initialized(ctx)?; session::session_subscribe(ctx.conn_id, params).await @@ -120,6 +134,10 @@ async fn dispatch_method( require_initialized(ctx)?; session::session_round(params).await } + "session/history" => { + require_initialized(ctx)?; + session::session_history(params).await + } "session/unsubscribe" => { require_initialized(ctx)?; session::session_unsubscribe(ctx.conn_id, params) @@ -134,6 +152,10 @@ async fn dispatch_method( require_full_tier(ctx)?; session::session_cancel(params).await } + "session/image" => { + require_initialized(ctx)?; + images::session_image(params).await + } "session/open_file" => { require_initialized(ctx)?; require_full_tier(ctx)?; @@ -171,6 +193,10 @@ async fn dispatch_method( require_initialized(ctx)?; interaction::pending(params).await } + "interaction/pending_all" => { + require_initialized(ctx)?; + interaction::pending_all(params).await + } "" => Err(RpcError::new( RpcErrorCode::InvalidRequest, "missing method", @@ -235,9 +261,14 @@ async fn handle_initialize(ctx: &mut RpcContext, params: &Value) -> Result Result RpcContext { @@ -287,16 +358,13 @@ mod tests { }); let response = dispatch(&mut ctx, &request).await.expect("response"); assert!(response.get("result").is_some()); - assert_eq!(response["result"]["capabilities"]["sessionIdentity"], true); - assert_eq!(response["result"]["capabilities"]["sessionSearch"], true); - let expected_identity = super::super::desktop_identity::collect(); - assert_eq!( - response["result"]["desktopIdentity"], - serde_json::to_value(&expected_identity).unwrap() - ); + let identity = response["result"]["desktopIdentity"].as_object().unwrap(); + assert!(identity + .keys() + .all(|key| matches!(key.as_str(), "name" | "model" | "username"))); assert_eq!( response["result"]["desktopName"], - serde_json::to_value(&expected_identity.name).unwrap() + response["result"]["desktopIdentity"]["name"] ); assert_eq!( response @@ -312,6 +380,55 @@ mod tests { ); assert_eq!(response["result"]["capabilities"]["sessionSearch"], true); assert!(ctx.initialized); + assert_eq!( + response["result"]["capabilities"]["pendingInteractions"], + true + ); + assert_eq!(response["result"]["capabilities"]["sessionSearch"], true); + assert_eq!(response["result"]["capabilities"]["sessionReadState"], true); + } + + #[tokio::test] + async fn read_receipts_require_initialization_and_validate_read_only_requests() { + let mut ctx = test_context(true); + for method in ["session/read_state", "session/mark_visited"] { + let response = dispatch(&mut ctx, &json!({"id":1, "method":method, "params":{}})) + .await + .unwrap(); + assert_eq!(response["error"]["message"], "connection not initialized"); + } + ctx.initialized = true; + ctx.tier = MobileTier::ReadOnly; + for method in ["session/read_state", "session/mark_visited"] { + let response = dispatch( + &mut ctx, + &json!({"id":2, "method":method, "params":{"sessionIds":[]}}), + ) + .await + .unwrap(); + assert_eq!( + response["error"]["code"], + RpcErrorCode::InvalidParams.as_i32() + ); + } + } + + #[tokio::test] + async fn pending_snapshots_require_initialization_and_read_only_cannot_answer() { + let mut ctx = test_context(true); + for method in ["interaction/pending", "interaction/pending_all"] { + let response = dispatch( + &mut ctx, + &json!({"jsonrpc":"2.0", "id":22, "method":method, "params":{}}), + ) + .await + .unwrap(); + assert_eq!(response["error"]["message"], "connection not initialized"); + } + ctx.initialized = true; + ctx.tier = MobileTier::ReadOnly; + let response = dispatch(&mut ctx, &json!({"jsonrpc":"2.0", "id":23, "method":"interaction/respond_permission", "params":{}})).await.unwrap(); + assert_eq!(response["error"]["code"], RpcErrorCode::TierDenied.as_i32()); } #[tokio::test] @@ -362,6 +479,22 @@ mod tests { ); } + #[tokio::test] + async fn images_require_initialize_but_allow_read_only_access() { + let mut ctx = test_context(true); + let request = json!({"jsonrpc":"2.0", "id":21, "method":"session/image", "params":{}}); + let response = dispatch(&mut ctx, &request).await.unwrap(); + assert_eq!(response["error"]["message"], "connection not initialized"); + ctx.initialized = true; + ctx.tier = MobileTier::ReadOnly; + let response = dispatch(&mut ctx, &request).await.unwrap(); + // Invalid params are rejected by the adapter, not by a write-tier gate. + assert_eq!( + response["error"]["code"], + RpcErrorCode::InvalidParams.as_i32() + ); + } + #[tokio::test] async fn initialize_preserves_relay_read_only_tier() { let mut ctx = test_context(true); diff --git a/src-tauri/src/api/mobile_bridge/ws_handler.rs b/src-tauri/src/api/mobile_bridge/ws_handler.rs index 46e107fda9..dd58e0f69e 100644 --- a/src-tauri/src/api/mobile_bridge/ws_handler.rs +++ b/src-tauri/src/api/mobile_bridge/ws_handler.rs @@ -5,14 +5,16 @@ use axum::extract::{Query, WebSocketUpgrade}; use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::Json; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{Sink, SinkExt, Stream, StreamExt}; use serde::Deserialize; use serde_json::{json, Value}; use tokio::sync::mpsc; +use tokio::task::JoinSet; use super::auth::{self, AuthFailure, MobileRemoteSettings}; use super::fanout; -use super::rpc::{self, MobileTier, RpcContext}; +use super::request_scheduler::{self, REQUEST_QUEUE_CAPACITY}; +use super::rpc::{MobileTier, RpcContext}; const MAX_WS_TEXT_BYTES: usize = 1024 * 1024; @@ -86,18 +88,47 @@ fn failure_response(failure: AuthFailure) -> Response { } async fn handle_mobile_socket(socket: WebSocket, settings: MobileRemoteSettings) { - let (mut sender, mut receiver) = socket.split(); + let (sender, receiver) = socket.split(); + handle_mobile_transport(sender, receiver, settings, request_scheduler::run).await; +} + +/// The socket owns both its fanout registration and every child task, including +/// when the server cancels the upgrade future instead of receiving a Close frame. +struct ConnectionRegistration(u64); + +impl Drop for ConnectionRegistration { + fn drop(&mut self) { + fanout::unregister_connection(self.0); + tracing::debug!(conn_id = self.0, "[MobileBridge] client disconnected"); + } +} + +async fn handle_mobile_transport( + mut sender: S, + mut receiver: R, + settings: MobileRemoteSettings, + run_scheduler: F, +) where + S: Sink + Unpin + Send + 'static, + R: Stream> + Unpin, + F: FnOnce(RpcContext, mpsc::Receiver, mpsc::Sender) -> Fut, + Fut: std::future::Future + Send + 'static, +{ let (outbound_tx, mut outbound_rx) = mpsc::channel::(64); let conn_id = fanout::register_connection(outbound_tx.clone()); + let _registration = ConnectionRegistration(conn_id); - let mut ctx = RpcContext { + let ctx = RpcContext { conn_id, initialized: false, tier: MobileTier::Full, settings, }; - let send_task = tokio::spawn(async move { + // JoinSet aborts all children on drop. Any child exiting (including a socket + // write failure) closes this connection and cancels its outstanding RPCs. + let mut tasks = JoinSet::new(); + tasks.spawn(async move { while let Some(message) = outbound_rx.recv().await { if sender.send(Message::Text(message.into())).await.is_err() { break; @@ -105,7 +136,29 @@ async fn handle_mobile_socket(socket: WebSocket, settings: MobileRemoteSettings) } }); - while let Some(result) = receiver.next().await { + let (request_tx, request_rx) = mpsc::channel(REQUEST_QUEUE_CAPACITY); + let (response_tx, mut response_rx) = mpsc::channel::(REQUEST_QUEUE_CAPACITY); + tasks.spawn(run_scheduler(ctx, request_rx, response_tx)); + let response_outbound_tx = outbound_tx.clone(); + tasks.spawn(async move { + while let Some(response) = response_rx.recv().await { + if response_outbound_tx + .send(response.to_string()) + .await + .is_err() + { + break; + } + } + }); + + loop { + let result = tokio::select! { + biased; + _ = tasks.join_next() => break, + result = receiver.next() => result, + }; + let Some(result) = result else { break }; let Ok(message) = result else { break; }; @@ -113,71 +166,75 @@ async fn handle_mobile_socket(socket: WebSocket, settings: MobileRemoteSettings) match message { Message::Text(text) => { if text.len() > MAX_WS_TEXT_BYTES { - let _ = outbound_tx - .send( - json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32600, - "message": "message too large", - } - }) - .to_string(), - ) - .await; + let _ = send_transport_error( + &outbound_tx, + Value::Null, + -32600, + "message too large", + ); break; } let parsed = match serde_json::from_str::(&text) { Ok(value) => value, Err(err) => { - let _ = outbound_tx - .send( - json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32600, - "message": format!("invalid json: {err}"), - } - }) - .to_string(), - ) - .await; + if !send_transport_error( + &outbound_tx, + Value::Null, + -32600, + &format!("invalid json: {err}"), + ) { + break; + } continue; } }; - if let Some(response) = rpc::dispatch(&mut ctx, &parsed).await { - if outbound_tx.send(response.to_string()).await.is_err() { - break; + // A saturated peer gets a bounded overload response rather than + // blocking receipt of control frames or retaining more requests. + match request_tx.try_send(parsed) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Closed(_)) => break, + Err(mpsc::error::TrySendError::Full(request)) => { + let id = request.get("id").cloned().unwrap_or(Value::Null); + if !send_transport_error(&outbound_tx, id, -32007, "server busy") { + break; + } } } } Message::Close(_) => break, Message::Ping(_) | Message::Pong(_) => {} Message::Binary(_) => { - let _ = outbound_tx - .send( - json!({ - "jsonrpc": "2.0", - "id": null, - "error": { - "code": -32600, - "message": "binary frames not supported", - } - }) - .to_string(), - ) - .await; + if !send_transport_error( + &outbound_tx, + Value::Null, + -32600, + "binary frames not supported", + ) { + break; + } } } } +} - send_task.abort(); - fanout::unregister_connection(conn_id); - tracing::debug!(conn_id, "[MobileBridge] client disconnected"); +fn send_transport_error( + outbound: &mpsc::Sender, + id: Value, + code: i64, + message: &str, +) -> bool { + outbound + .try_send( + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) + .to_string(), + ) + .is_ok() } #[cfg(test)] @@ -185,6 +242,258 @@ mod tests { use super::auth::{token_matches, AuthFailure, MobileRemoteSettings}; use super::*; use axum::http::StatusCode; + use std::sync::Arc; + use tokio::sync::Semaphore; + use tokio::time::{timeout, Duration}; + + struct TestTransport { + input: mpsc::Sender, + output: mpsc::Receiver, + task: tokio::task::JoinHandle<()>, + } + + impl Drop for TestTransport { + fn drop(&mut self) { + self.task.abort(); + } + } + + impl TestTransport { + async fn request(&self, id: u64, method: &str) { + self.input + .send(Message::Text( + json!({ + "jsonrpc": "2.0", "id": id, "method": method, + }) + .to_string() + .into(), + )) + .await + .unwrap(); + } + + async fn response(&mut self) -> Value { + let message = timeout(Duration::from_secs(2), self.output.recv()) + .await + .expect("transport must remain responsive") + .unwrap(); + let Message::Text(text) = message else { + panic!("expected JSON text") + }; + serde_json::from_str(&text).unwrap() + } + } + + fn test_transport(run: F) -> TestTransport + where + F: FnOnce(RpcContext, mpsc::Receiver, mpsc::Sender) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, + { + let (input, incoming) = mpsc::channel::(128); + let (outgoing, output) = mpsc::channel::(128); + let receiver = Box::pin(futures_util::stream::unfold( + incoming, + |mut incoming| async { + incoming + .recv() + .await + .map(|message| (Ok::<_, std::io::Error>(message), incoming)) + }, + )); + let sender = Box::pin(futures_util::sink::unfold( + outgoing, + |outgoing, message| async { + outgoing + .send(message) + .await + .map_err(|_| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?; + Ok::<_, std::io::Error>(outgoing) + }, + )); + let task = tokio::spawn(handle_mobile_transport( + sender, + receiver, + MobileRemoteSettings { + enabled: true, + lan_token: "test-token".into(), + allow_lan_exposure: true, + }, + run, + )); + TestTransport { + input, + output, + task, + } + } + + struct OnDrop(mpsc::UnboundedSender<()>); + + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + + async fn transport_with_pending_read() -> ( + TestTransport, + u64, + Arc, + mpsc::UnboundedReceiver<()>, + ) { + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (dropped_tx, dropped_rx) = mpsc::unbounded_channel(); + let release = Arc::new(Semaphore::new(0)); + let executor_release = release.clone(); + let mut transport = test_transport(move |ctx, requests, responses| { + request_scheduler::run_with_executor( + ctx, + requests, + responses, + move |mut ctx, request| { + let started_tx = started_tx.clone(); + let dropped_tx = dropped_tx.clone(); + let release = executor_release.clone(); + async move { + if request["method"] == "initialize" { + ctx.initialized = true; + } else if request["method"] == "session/list" { + let _drop = OnDrop(dropped_tx); + started_tx.send(ctx.conn_id).unwrap(); + let _permit = release.acquire().await.unwrap(); + } + let response = + json!({ "jsonrpc": "2.0", "id": request["id"], "result": {} }); + (ctx, Some(response)) + } + }, + ) + }); + transport.request(1, "initialize").await; + assert_eq!(transport.response().await["id"], 1); + transport.request(2, "session/list").await; + let conn_id = timeout(Duration::from_secs(2), started_rx.recv()) + .await + .unwrap() + .unwrap(); + (transport, conn_id, release, dropped_rx) + } + + #[tokio::test] + async fn pending_read_keeps_later_requests_validation_and_fanout_responsive() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + let (mut transport, conn_id, release, _dropped) = transport_with_pending_read().await; + transport.request(3, "models/list").await; + assert_eq!(transport.response().await["id"], 3); + + transport + .input + .send(Message::Text("{".into())) + .await + .unwrap(); + assert_eq!(transport.response().await["error"]["code"], -32600); + transport + .input + .send(Message::Binary(vec![1].into())) + .await + .unwrap(); + assert_eq!( + transport.response().await["error"]["message"], + "binary frames not supported" + ); + + let session_id = format!("lan-pending-read-{conn_id}"); + assert!(fanout::subscribe_session(conn_id, &session_id)); + fanout::fanout_to_session( + &session_id, + r#"{"jsonrpc":"2.0","method":"test/notification"}"#, + ); + assert_eq!(transport.response().await["method"], "test/notification"); + + release.add_permits(1); + assert_eq!(transport.response().await["id"], 2); + transport.input.send(Message::Close(None)).await.unwrap(); + timeout(Duration::from_secs(2), &mut transport.task) + .await + .unwrap() + .unwrap(); + assert!(!fanout::subscribe_session(conn_id, &session_id)); + } + + #[tokio::test] + async fn close_abort_and_write_failure_cancel_pending_reads_and_unregister() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + for terminal in ["close", "abort", "write_failure"] { + let (mut transport, conn_id, _release, mut dropped) = + transport_with_pending_read().await; + match terminal { + "close" => transport.input.send(Message::Close(None)).await.unwrap(), + "abort" => transport.task.abort(), + _ => { + transport.output.close(); + // A validation response exercises the real writer failure path. + transport + .input + .send(Message::Text("{".into())) + .await + .unwrap(); + } + } + let completed = timeout(Duration::from_secs(2), &mut transport.task) + .await + .unwrap(); + if terminal == "abort" { + assert!(completed.unwrap_err().is_cancelled()); + } else { + completed.unwrap(); + } + assert_eq!( + timeout(Duration::from_secs(2), dropped.recv()) + .await + .unwrap(), + Some(()) + ); + assert!(!fanout::subscribe_session(conn_id, "disconnected")); + } + } + + #[tokio::test] + async fn full_request_queue_replies_busy_and_still_accepts_close() { + let _registry = fanout::TEST_REGISTRY_LOCK.lock().await; + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (dropped_tx, mut dropped_rx) = mpsc::unbounded_channel(); + let mut transport = test_transport(move |ctx, requests, responses| async move { + let _requests = requests; + // Keep both scheduler channels alive while intentionally refusing + // input; a closed response channel correctly terminates transport. + let _responses = responses; + let _drop = OnDrop(dropped_tx); + started_tx.send(ctx.conn_id).unwrap(); + std::future::pending::<()>().await; + }); + let conn_id = timeout(Duration::from_secs(2), started_rx.recv()) + .await + .unwrap() + .unwrap(); + for id in 0..=REQUEST_QUEUE_CAPACITY { + transport.request(id as u64, "session/list").await; + } + let response = transport.response().await; + assert_eq!(response["id"], REQUEST_QUEUE_CAPACITY); + assert_eq!(response["error"]["code"], -32007); + transport.input.send(Message::Close(None)).await.unwrap(); + timeout(Duration::from_secs(2), &mut transport.task) + .await + .unwrap() + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), dropped_rx.recv()) + .await + .unwrap(), + Some(()) + ); + assert!(!fanout::subscribe_session(conn_id, "overloaded")); + } #[test] fn validate_token_fails_when_feature_disabled() { diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 3e32645164..58c71c38b8 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -410,6 +410,8 @@ api::mobile_bridge::commands::mobile_remote_get_relay_url, api::mobile_bridge::commands::mobile_remote_relay_status, api::mobile_bridge::commands::mobile_remote_notify_cloud_auth_changed, api::mobile_bridge::commands::mobile_remote_sync_sidebar_sessions, +api::mobile_bridge::read_state::mobile_remote_reply_read_state, +api::mobile_bridge::read_state::mobile_remote_read_state_changed, // Code session commands (spawn CLI agents, manage sessions) agent_sessions::cli::commands::cli_agent_create, agent_sessions::cli::native_materializer::materialize_native_conversation, diff --git a/src-tauri/src/orgtrack/diff_commands.rs b/src-tauri/src/orgtrack/diff_commands.rs index 59a46011d4..b9259d0ab7 100644 --- a/src-tauri/src/orgtrack/diff_commands.rs +++ b/src-tauri/src/orgtrack/diff_commands.rs @@ -50,6 +50,13 @@ pub async fn orgtrack_get_session_edit_artifacts( record_orgtrack_command_call("orgtrack_get_session_edit_artifacts"); tokio::task::spawn_blocking(move || { let conn = get_connection().map_err(|err| err.to_string())?; + if source.as_deref().is_none_or(|s| s == "codex_app") { + if let Some(id) = session_id.as_deref() { + if let Some(imported) = super::imported_changes::read(&conn, id)? { + return Ok(imported.edits); + } + } + } let store = SqliteRecordStore::new(&conn); store.list_edit_artifacts(source.as_deref(), session_id.as_deref()) }) @@ -65,6 +72,13 @@ pub async fn orgtrack_get_session_diff_chunks( record_orgtrack_command_call("orgtrack_get_session_diff_chunks"); tokio::task::spawn_blocking(move || { let conn = get_connection().map_err(|err| err.to_string())?; + if source.as_deref().is_none_or(|s| s == "codex_app") { + if let Some(id) = session_id.as_deref() { + if let Some(imported) = super::imported_changes::read(&conn, id)? { + return Ok(imported.chunks); + } + } + } let store = SqliteRecordStore::new(&conn); store.list_diff_chunks(source.as_deref(), session_id.as_deref()) }) @@ -80,6 +94,23 @@ pub async fn orgtrack_get_session_final_diffs( record_orgtrack_command_call("orgtrack_get_session_final_diffs"); tokio::task::spawn_blocking(move || { let conn = get_connection().map_err(|err| err.to_string())?; + if source.as_deref().is_none_or(|s| s == "codex_app") { + if let Some(id) = session_id.as_deref() { + if let Some(imported) = super::imported_changes::read(&conn, id)? { + let paths: std::collections::BTreeSet<_> = imported + .chunks + .iter() + .map(|chunk| chunk.file_path.as_str()) + .collect(); + return Ok(paths + .into_iter() + .filter_map(|path| { + final_diff_from_chunks("codex_app", id, path, &imported.chunks) + }) + .collect()); + } + } + } let store = SqliteRecordStore::new(&conn); let final_diffs = store.list_final_diffs(source.as_deref(), session_id.as_deref())?; let final_diffs = if let Some(session_id) = session_id.as_deref() { diff --git a/src-tauri/src/orgtrack/imported_changes.rs b/src-tauri/src/orgtrack/imported_changes.rs new file mode 100644 index 0000000000..49285f4e6c --- /dev/null +++ b/src-tauri/src/orgtrack/imported_changes.rs @@ -0,0 +1,487 @@ +//! Read-only projection of imported Codex edits. The rollout, not the native +//! runtime artifact table or today's worktree, is authoritative for this owner. +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, + time::SystemTime, +}; + +use crate::agent_sessions::event_pipeline::{ + extractors::extract_event_data, + ingestion::{ingest_raw_chunks, types::RawActivityChunk}, +}; +use orgtrack_core::{ + edit_extraction::{ + artifacts_from_extracted_edit, EditArtifactContext, NormalizedEditArtifacts, + }, + sources::codex::app, +}; + +pub(crate) mod reconstruction; +use reconstruction::{FileVersion, RecordedEdit}; + +#[derive(Debug, Clone, Default)] +struct Review { + artifacts: NormalizedEditArtifacts, + history: Vec, +} + +type Key = (String, PathBuf, SystemTime, u64); +// One bounded entry; serializing reads also single-flights Desktop/mobile. +static REVIEW: Mutex)>> = Mutex::new(None); +type ContextKey = (Key, String, String, String); +static CONTEXT_REVIEW: Mutex)>> = Mutex::new(None); + +pub(crate) fn read( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + Ok(read_review(conn, session_id)?.map(|review| review.artifacts.clone())) +} + +pub(crate) fn read_file_version( + conn: &rusqlite::Connection, + session_id: &str, + path: &str, + range: Option<(&str, &str, &str)>, +) -> Result { + if let Some((round, first, last)) = range { + let canonical = crate::agent_sessions::cli::native_transcript::imported_transcript_id_for_managed_session(session_id); + let session_id = canonical.as_deref().unwrap_or(session_id); + if !session_id.starts_with(orgtrack_core::sources::codex::SESSION_PREFIX) { + return Ok(FileVersion::default()); + } + let source = app::codex_app_review_path(conn, session_id)?; + let signature = || -> Result { + let meta = std::fs::metadata(&source).map_err(|e| e.to_string())?; + Ok(( + ( + session_id.into(), + source.clone(), + meta.modified().map_err(|e| e.to_string())?, + meta.len(), + ), + round.into(), + path.into(), + last.into(), + )) + }; + let key = signature()?; + let mut cache = CONTEXT_REVIEW + .lock() + .map_err(|_| "file review lock poisoned")?; + let review = match cache.as_ref() { + Some((previous, review)) if previous == &key => review.clone(), + _ => { + *cache = None; + let chunks = + app::load_codex_app_review_context_from_path(session_id, &source, round, last)?; + let review = Arc::new(project_selected_review(session_id, chunks, Some(path))?); + if signature()? != key { + return Err("History changed during review; retry".into()); + } + *cache = Some((key, review.clone())); + review + } + }; + return Ok(reconstruction::replay( + &review.history, + path, + Some((first, last)), + None, + )); + } + Ok( + read_review(conn, session_id)?.map_or_else(FileVersion::default, |review| { + reconstruction::replay(&review.history, path, None, None) + }), + ) +} + +fn read_review( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result>, String> { + let canonical = + crate::agent_sessions::cli::native_transcript::imported_transcript_id_for_managed_session( + session_id, + ); + let session_id = canonical.as_deref().unwrap_or(session_id); + if !session_id.starts_with(orgtrack_core::sources::codex::SESSION_PREFIX) { + return Ok(None); + } + let path = app::codex_app_review_path(conn, session_id)?; + let signature = || -> Result { + let meta = std::fs::metadata(&path).map_err(|e| e.to_string())?; + Ok(( + session_id.into(), + path.clone(), + meta.modified().map_err(|e| e.to_string())?, + meta.len(), + )) + }; + let mut cache = REVIEW.lock().map_err(|_| "imported review lock poisoned")?; + let key = signature()?; + if let Some((previous, value)) = cache.as_ref() { + if previous == &key { + return Ok(Some(value.clone())); + } + } + // Drop stale data before parsing; a failed read cannot serve the old owner. + *cache = None; + let chunks = app::load_codex_app_review_from_path(session_id, &path)?; + let value = Arc::new(project_review(session_id, chunks)?); + if signature()? != key { + return Err("History changed during review; retry".into()); + } + *cache = Some((key, value.clone())); + Ok(Some(value)) +} + +fn project_review( + session_id: &str, + chunks: Vec, +) -> Result { + project_selected_review(session_id, chunks, None) +} + +fn project_selected_review( + session_id: &str, + chunks: Vec, + selected_file: Option<&str>, +) -> Result { + let raw: Vec = chunks + .into_iter() + .map(|chunk| RawActivityChunk { + chunk_id: Some(chunk.chunk_id), + session_id: Some(session_id.into()), + action_type: Some(chunk.action_type), + function: Some(chunk.function), + args: Some(chunk.args), + result: Some(chunk.result), + created_at: Some(chunk.created_at), + ..Default::default() + }) + .collect(); + let mut output = NormalizedEditArtifacts::default(); + let mut history = Vec::new(); + let mut history_bytes = 0; + let mut bytes = 0; + let mut turn = None; + for (index, event) in ingest_raw_chunks(&raw, session_id) + .events + .into_iter() + .enumerate() + { + if event.action_type == "user_message" { + turn = Some(event.id.clone()); + } + if event.display_status != core_types::session_event::EventDisplayStatus::Completed { + continue; + } + let Some(core_types::extracted::ExtractedData::Edit(edit)) = extract_event_data(&event) + else { + continue; + }; + let context = EditArtifactContext { + source: "codex_app".into(), + source_session_id: None, + session_id: session_id.into(), + source_event_id: Some(event.id.clone()), + turn_id: turn.clone(), + sequence_index: index as i64, + timestamp: Some(event.created_at.clone()), + workspace_path: None, + metadata: Default::default(), + }; + let mut artifacts = artifacts_from_extracted_edit(&context, &edit); + if let Some(path) = selected_file { + artifacts.chunks.retain(|chunk| chunk.file_path == path); + artifacts.edits.retain(|edit| edit.file_path == path); + if artifacts.chunks.is_empty() { + continue; + } + } + let mut recorded = RecordedEdit::from_event( + &event, + artifacts + .chunks + .iter() + .map(|c| c.file_path.clone()) + .collect(), + ); + history_bytes += recorded.patch.as_ref().map_or(0, String::len); + if history_bytes > 2 * 1024 * 1024 { + // Keep the invalidating event, not its body. Exhausting the replay + // budget must not make the existing Desktop diff list fail. + recorded.patch = None; + } + for chunk in &artifacts.chunks { + bytes += chunk.diff.as_ref().map_or(0, String::len) + + chunk.old_content.as_ref().map_or(0, String::len) + + chunk.new_content.as_ref().map_or(0, String::len); + } + if bytes > 2 * 1024 * 1024 || output.chunks.len() + artifacts.chunks.len() > 500 { + return Err("Imported changes exceed review budget".into()); + } + output.edits.extend(artifacts.edits); + output.chunks.extend(artifacts.chunks); + history.push(recorded); + } + Ok(Review { + artifacts: output, + history, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn project( + session_id: &str, + chunks: Vec, + ) -> Result { + Ok(project_review(session_id, chunks)?.artifacts) + } + #[test] + #[ignore = "explicit read-only verification against a user-selected rollout"] + fn verify_selected_rollout() { + let path = std::env::var("ORGII_REVIEW_VERIFY_PATH").expect("select a rollout"); + let start = std::time::Instant::now(); + let result = project( + "codexapp-verification", + app::load_codex_app_review_from_path( + "codexapp-verification", + std::path::Path::new(&path), + ) + .unwrap(), + ) + .unwrap(); + let files: std::collections::BTreeSet<_> = + result.chunks.iter().map(|c| c.file_path.as_str()).collect(); + eprintln!( + "review: {} edits, {} files, {:?}", + result.chunks.len(), + files.len(), + start.elapsed() + ); + assert!(!files.is_empty()); + } + #[test] + fn exec_rollout_projects_successful_edits_without_native_artifact_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let patch = "*** Begin Patch\n*** Add File: src/example.ts\n+hello\n*** End Patch"; + let input = format!( + "text(await tools.apply_patch({}));", + serde_json::to_string(patch).unwrap() + ); + let records = [ + serde_json::json!({"type":"response_item","payload":{"type":"custom_tool_call","call_id":"edit-1","name":"exec","input":input}}), + serde_json::json!({"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"edit-1","output":[{"type":"input_text","text":"Script completed\nWall time 0.0 seconds\nOutput:\n"},{"type":"input_text","text":"{}"}]}}), + ]; + std::fs::write( + &path, + records + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + let result = project( + "codexapp-test", + app::load_codex_app_review_from_path("codexapp-test", &path).unwrap(), + ) + .unwrap(); + assert_eq!(result.edits.len(), 1); + assert_eq!(result.chunks[0].file_path, "src/example.ts"); + assert_eq!(result.chunks[0].session_id, "codexapp-test"); + assert!(result.chunks[0].diff.as_deref().unwrap().contains("+hello")); + std::fs::write(&path, records[0].to_string()).unwrap(); + assert!( + project( + "codexapp-test", + app::load_codex_app_review_from_path("codexapp-test", &path).unwrap() + ) + .unwrap() + .chunks + .is_empty(), + "pending intent must not count as a completed edit" + ); + let failed = records[1] + .to_string() + .replace("Script completed", "Script failed"); + std::fs::write(&path, format!("{}\n{}", records[0], failed)).unwrap(); + assert!(project( + "codexapp-test", + app::load_codex_app_review_from_path("codexapp-test", &path).unwrap() + ) + .unwrap() + .chunks + .is_empty()); + } + + #[test] + fn raw_rollout_replays_full_file_with_unmodified_lines_and_stable_event_boundaries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let patches = [ + "*** Begin Patch\n*** Add File: a\n+head\n+one\n+gap\n+two\n+tail\n*** End Patch", + "*** Begin Patch\n*** Update File: a\n@@\n-one\n+ONE\n@@\n-two\n+TWO\n*** End Patch", + "*** Begin Patch\n*** Update File: a\n@@\n-tail\n+later\n*** End Patch", + ]; + let mut records = Vec::new(); + for (i, patch) in patches.iter().enumerate() { + records.push(serde_json::json!({"type":"event_msg","payload":{"type":"user_message","message":format!("turn {i}")}})); + records.push(serde_json::json!({"type":"response_item","payload":{"type":"custom_tool_call","call_id":format!("edit-{i}"),"name":"exec","input":format!("text(await tools.apply_patch({})); text(await tools.exec_command({{cmd:\"check\"}}));", serde_json::to_string(patch).unwrap())}})); + records.push(serde_json::json!({"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":format!("edit-{i}"),"output":[{"type":"input_text","text":"Script completed\nOutput:\n"},{"type":"input_text","text":"{}"},{"type":"input_text","text":if i == 0 {r#"{"session_id":71,"output":"building"}"#} else {r#"{"exit_code":1,"output":"check failed"}"#}}]}})); + } + std::fs::write( + &path, + records + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + let review = project_review( + "codexapp-test", + app::load_codex_app_review_from_path("codexapp-test", &path).unwrap(), + ) + .unwrap(); + assert_eq!(review.history.len(), 3); + let target = &review.history[1].event_id; + let version = reconstruction::replay(&review.history, "a", Some((target, target)), None); + assert_eq!( + version.before.as_deref(), + Some("head\none\ngap\ntwo\ntail\n") + ); + assert_eq!( + version.after.as_deref(), + Some("head\nONE\ngap\nTWO\ntail\n") + ); + // The display conversion flattens @@ sections. Replay retains the raw + // sections and must not rely on the synthetic contiguous line range. + assert!(review.history[1] + .patch + .as_deref() + .unwrap() + .contains("+ONE\n@@")); + let version = reconstruction::replay(&review.history, "a", None, None); + assert_eq!( + version.after.as_deref(), + Some("head\nONE\ngap\nTWO\nlater\n") + ); + let target_offset: usize = records[..3].iter().map(|r| r.to_string().len() + 1).sum(); + let context = project_selected_review( + "codexapp-test", + app::load_codex_app_review_context_from_path( + "codexapp-test", + &path, + &format!("codex-user-{target_offset}"), + "edit-1:part-0", + ) + .unwrap(), + Some("a"), + ) + .unwrap(); + assert_eq!(context.history.len(), 2, "stop before later turns"); + let scoped = reconstruction::replay(&context.history, "a", Some((target, target)), None); + assert_eq!(scoped.after.as_deref(), Some("head\nONE\ngap\nTWO\ntail\n")); + // A sparse old prefix puts the same fixture beyond the whole-rollout + // budget. Review must seek to nearby turns, not read that prefix. + use std::io::{Seek, SeekFrom, Write}; + let prefix = 70 * 1024 * 1024; + let mut large = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .unwrap(); + large.set_len(prefix).unwrap(); + large.seek(SeekFrom::End(0)).unwrap(); + writeln!(large).unwrap(); + large + .write_all( + records + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + .as_bytes(), + ) + .unwrap(); + drop(large); + let chunks = app::load_codex_app_review_context_from_path( + "codexapp-test", + &path, + &format!("codex-user-{}", prefix + 1 + target_offset as u64), + "edit-1:part-0", + ) + .unwrap(); + let context = project_selected_review("codexapp-test", chunks, Some("a")).unwrap(); + assert_eq!(context.history.len(), 2); + assert_eq!( + reconstruction::replay(&context.history, "a", Some((target, target)), None).after, + scoped.after + ); + } + + #[test] + #[ignore = "read-only verification of explicitly selected user history"] + fn verify_selected_file_version() { + let path = std::env::var("ORGII_REVIEW_VERIFY_PATH").unwrap(); + let file = std::env::var("ORGII_REVIEW_VERIFY_FILE").unwrap(); + let marker = std::env::var("ORGII_REVIEW_VERIFY_MARKER").unwrap(); + let round = std::env::var("ORGII_REVIEW_VERIFY_ROUND").unwrap(); + let call = std::env::var("ORGII_REVIEW_VERIFY_CALL").unwrap(); + let start = std::time::Instant::now(); + let review = project_selected_review( + "codexapp-verification", + app::load_codex_app_review_context_from_path( + "codexapp-verification", + std::path::Path::new(&path), + &round, + &call, + ) + .unwrap(), + Some(&file), + ) + .unwrap(); + let edits: Vec<_> = review + .history + .iter() + .filter(|e| e.paths.contains(&file)) + .collect(); + for edit in &edits { + eprintln!( + "file edit {}, patch bytes {}", + edit.event_id, + edit.patch.as_ref().map_or(0, String::len) + ); + } + let target = edits + .iter() + .find(|e| e.patch.as_deref().is_some_and(|p| p.contains(&marker))) + .expect("selected event exists"); + let version = reconstruction::replay( + &review.history, + &file, + Some((&target.event_id, &target.event_id)), + None, + ); + eprintln!( + "version: before {:?} bytes, after {:?} bytes, elapsed {:?}", + version.before.as_ref().map(String::len), + version.after.as_ref().map(String::len), + start.elapsed() + ); + assert!(version.before.as_ref().is_some_and(|s| s.starts_with('#'))); + assert!(version.after.as_ref().is_some_and(|s| s.starts_with('#'))); + assert!(version + .after + .as_ref() + .is_some_and(|s| s.contains("VERIFIED"))); + } +} diff --git a/src-tauri/src/orgtrack/imported_changes/reconstruction.rs b/src-tauri/src/orgtrack/imported_changes/reconstruction.rs new file mode 100644 index 0000000000..048c8af5a8 --- /dev/null +++ b/src-tauri/src/orgtrack/imported_changes/reconstruction.rs @@ -0,0 +1,302 @@ +//! Replay recorded file edits, never the current filesystem. A full creation is +//! an anchor; a fragment alone is not. Unknown edits or ambiguous context break +//! the chain until another full creation. All limits are per selected file. +use core_types::session_event::SessionEvent; + +const MAX_FILE_BYTES: usize = 512 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct RecordedEdit { + pub event_id: String, + pub paths: Vec, + pub patch: Option, +} + +impl RecordedEdit { + pub fn from_event(event: &SessionEvent, paths: Vec) -> Self { + Self { + // Display IDs include reader-relative sequence numbers. The + // provider call ID remains stable across paginated/full reads. + event_id: event.call_id.clone().unwrap_or_else(|| event.id.clone()), + paths, + patch: event + .args + .get("patch_text") + .or_else(|| event.args.get("patch")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + } + } +} + +#[derive(Debug, Default)] +pub(crate) struct FileVersion { + pub before: Option, + pub after: Option, +} + +/// `range` uses exact authoritative event identities, not timestamps/titles or +/// indices from a separately paginated transcript. Later edits are never read +/// into the selected version. A missing boundary yields no version. +pub(crate) fn replay( + history: &[RecordedEdit], + path: &str, + range: Option<(&str, &str)>, + initial: Option, +) -> FileVersion { + let edits: Vec<_> = history + .iter() + .filter(|e| e.paths.iter().any(|p| p == path)) + .collect(); + let (first, last) = match range { + Some((first, last)) => match ( + edits.iter().position(|e| e.event_id == first), + edits.iter().position(|e| e.event_id == last), + ) { + (Some(first), Some(last)) if first <= last => (first, last), + _ => return FileVersion::default(), + }, + None if !edits.is_empty() => (0, edits.len() - 1), + None => return FileVersion::default(), + }; + let mut content = initial.filter(|s| valid_text(s)); + let mut before = None; + for (index, edit) in edits.iter().enumerate().take(last + 1) { + if index == first { + before = content.clone(); + } + let next = edit + .patch + .as_deref() + .and_then(|patch| apply(patch, path, content.as_deref())); + // Add File proves the resulting contents, not historical absence: + // some providers allow Add to overwrite an existing file. + content = next; + } + FileVersion { + before, + after: content, + } +} + +fn valid_text(text: &str) -> bool { + text.len() <= MAX_FILE_BYTES && !text.contains('\0') +} + +/// Interpret the *original* patch: display conversion may synthesize line 1 +/// and flatten unnumbered @@ boundaries, so its diff is not a replay source. +fn apply(patch: &str, path: &str, base: Option<&str>) -> Option { + if patch.len() > 2 * 1024 * 1024 + || !patch.starts_with("*** Begin Patch\n") + || !patch.trim_end().ends_with("*** End Patch") + { + return None; + } + let lines: Vec<_> = patch.lines().collect(); + let headers: Vec<_> = lines + .iter() + .enumerate() + .filter_map(|(i, line)| { + ["*** Add File: ", "*** Update File: ", "*** Delete File: "] + .iter() + .enumerate() + .find_map(|(kind, prefix)| { + line.strip_prefix(prefix) + .filter(|p| *p == path) + .map(|_| (i, kind)) + }) + }) + .collect(); + // Repeated sections for one file have uncertain intra-call semantics. + if headers.len() != 1 { + return None; + } + let (start, kind) = headers[0]; + let end = (start + 1..lines.len()).find(|&i| { + lines[i].starts_with("*** Add File: ") + || lines[i].starts_with("*** Update File: ") + || lines[i].starts_with("*** Delete File: ") + || lines[i] == "*** End Patch" + })?; + let body = &lines[start + 1..end]; + let text = match kind { + 0 => { + let mut text = String::new(); + for line in body { + text.push_str(line.strip_prefix('+')?); + text.push('\n'); + if !valid_text(&text) { + return None; + } + } + text + } + 1 => update(base?, body)?, + 2 if body.is_empty() => String::new(), + _ => return None, + }; + valid_text(&text).then_some(text) +} + +fn update(base: &str, body: &[&str]) -> Option { + let mut text = base.to_owned(); + let mut cursor = 0; + let mut start = 0; + let mut saw_hunk = false; + // Preserve each @@ boundary. No fuzzy/whitespace matching and no guessed + // insertion point: exactly one occurrence of the old hunk is required. + for end in 0..=body.len() { + if end < body.len() && !body[end].starts_with("@@") { + continue; + } + if end > start { + let mut old = String::new(); + let mut new = String::new(); + for line in &body[start..end] { + let rest = line.get(1..)?; + match line.as_bytes().first()? { + b' ' => { + old.push_str(rest); + old.push('\n'); + new.push_str(rest); + new.push('\n'); + } + b'-' => { + old.push_str(rest); + old.push('\n'); + } + b'+' => { + new.push_str(rest); + new.push('\n'); + } + _ => return None, // includes rename, EOF markers, malformed input + } + } + if old.is_empty() { + return None; + } + let mut found = None; + let mut search = cursor; + while let Some(offset) = text[search..].find(&old) { + let at = search + offset; + if (at == 0 || text.as_bytes()[at - 1] == b'\n') + && found.replace(at).is_some() + { + return None; + } + // Advance one UTF-8 character, not the whole match, so + // overlapping repeated hunks are ambiguous too. + search = at + text[at..].chars().next()?.len_utf8(); + } + let at = found?; + if text.len() - old.len() + new.len() > MAX_FILE_BYTES { + return None; + } + text.replace_range(at..at + old.len(), &new); + cursor = at + new.len(); + saw_hunk = true; + } + start = end + 1; + } + saw_hunk.then_some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + fn edit(id: &str, body: &str) -> RecordedEdit { + RecordedEdit { + event_id: id.into(), + paths: vec!["a".into()], + patch: Some(format!("*** Begin Patch\n{body}\n*** End Patch")), + } + } + #[test] + fn anchors_and_replays_only_through_selected_event() { + let edits = [ + edit("create", "*** Add File: a\n+head\n+one\n+gap\n+two\n+tail"), + edit( + "update", + "*** Update File: a\n@@\n-one\n+ONE\n@@\n-two\n+TWO", + ), + edit("later", "*** Update File: a\n@@\n-tail\n+TODAY"), + ]; + let version = replay(&edits, "a", Some(("update", "update")), None); + assert_eq!( + version.before.as_deref(), + Some("head\none\ngap\ntwo\ntail\n") + ); + assert_eq!( + version.after.as_deref(), + Some("head\nONE\ngap\nTWO\ntail\n") + ); + assert!(replay(&edits, "other", None, None).after.is_none()); + assert!(replay(&edits, "a", Some(("missing", "update")), None) + .after + .is_none()); + assert!(replay(&edits, "a", None, None).before.is_none()); + } + #[test] + fn rejects_fragment_anchor_mismatch_ambiguity_and_unknown_edit() { + let patch = edit("update", "*** Update File: a\n@@\n-one\n+ONE"); + assert!(replay(std::slice::from_ref(&patch), "a", None, None).after.is_none()); + for base in ["wrong\n", "one\none\n", "someone\n"] { + assert!(replay(std::slice::from_ref(&patch), "a", None, Some(base.into())) + .after + .is_none()); + } + let edits = [ + edit("create", "*** Add File: a\n+one"), + RecordedEdit { + event_id: "unknown".into(), + paths: vec!["a".into()], + patch: None, + }, + patch, + ]; + assert!(replay(&edits, "a", None, None).after.is_none()); + assert!(replay( + &[edit("overlap", "*** Update File: a\n@@\n-a\n-a\n+b")], + "a", + None, + Some("a\na\na\n".into()) + ) + .after + .is_none()); + } + #[test] + fn preserves_header_like_code_unicode_and_empty_deletion() { + let edits = [ + edit("create", "*** Add File: a\n+---old\n+你好"), + edit("update", "*** Update File: a\n@@\n----old\n++++new"), + edit("delete", "*** Delete File: a"), + ]; + assert_eq!( + replay(&edits, "a", Some(("update", "update")), None) + .after + .as_deref(), + Some("+++new\n你好\n") + ); + assert_eq!( + replay(&edits, "a", Some(("delete", "delete")), None) + .after + .as_deref(), + Some("") + ); + } + #[test] + fn rejects_oversize_binary_malformed_and_unsupported_rename() { + for body in [ + format!("*** Add File: a\n+{}", "a".repeat(MAX_FILE_BYTES)), + "*** Add File: a\n+binary\0".into(), + "*** Update File: a\n*** Move to: b\n@@\n-one\n+two".into(), + ] { + assert!( + replay(&[edit("edit", &body)], "a", None, Some("one\n".into())) + .after + .is_none() + ); + } + assert!(apply("*** Begin Patch\n*** Add File: a\n+truncated", "a", None).is_none()); + } +} diff --git a/src-tauri/src/orgtrack/mod.rs b/src-tauri/src/orgtrack/mod.rs index bd01485cfb..374b9d9266 100644 --- a/src-tauri/src/orgtrack/mod.rs +++ b/src-tauri/src/orgtrack/mod.rs @@ -13,6 +13,7 @@ pub mod usage_dashboard_commands; mod command_stats; mod diff_commands; +pub(crate) mod imported_changes; mod file_session_history; use command_stats::record_orgtrack_command_call; diff --git a/src/app/root/AppBootstrap.tsx b/src/app/root/AppBootstrap.tsx index 71c93b338e..6db00febeb 100644 --- a/src/app/root/AppBootstrap.tsx +++ b/src/app/root/AppBootstrap.tsx @@ -44,6 +44,7 @@ import { useAppShellEffects } from "./useAppShellEffects"; import { useFirstPaintSignal } from "./useFirstPaintSignal"; import { useMobileRelayCloudAuthSync } from "./useMobileRelayCloudAuthSync"; import { useMobileRemoteDesktopActions } from "./useMobileRemoteDesktopActions"; +import { useMobileSessionReadStateSync } from "./useMobileSessionReadStateSync"; import { usePostPaintGitProbe } from "./usePostPaintGitProbe"; // The E2E bridge (`window.__e2e`) is dev-only and loads as its own chunk: its @@ -88,6 +89,7 @@ export const AppBootstrap: FC = () => { useModelAliasRegistry(); useDiagnosticsBootstrap(); useMobileRemoteDesktopActions(); + useMobileSessionReadStateSync(); useMobileRelayCloudAuthSync(); useDataSourceAutoScan(); // Keep external-history sources fresh on their cadence useAgentLiveStatusSync(); // Hook-driven live agent status → sidebar dots diff --git a/src/app/root/useMobileSessionReadStateSync.ts b/src/app/root/useMobileSessionReadStateSync.ts new file mode 100644 index 0000000000..8542542e3c --- /dev/null +++ b/src/app/root/useMobileSessionReadStateSync.ts @@ -0,0 +1,39 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect } from "react"; + +import { createLogger } from "@src/hooks/logger"; +import { useSetting } from "@src/hooks/settings/useSettings"; +import { startDesktopReadStateBridge } from "@src/store/session/desktopReadStateBridge"; +import { + markAllSessionsVisitedPersisted, + visitedSessionsAtom, +} from "@src/store/session/visitedSessionsAtom"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; + +const logger = createLogger("MobileSessionReadState"); + +export function useMobileSessionReadStateSync(): void { + const [enabled] = useSetting("mobileRemote.enabled"); + useEffect(() => { + if (!enabled || !isTauri() || getCurrentWindow().label !== "main") return; + const store = getInstrumentedStore(); + return startDesktopReadStateBridge({ + listen: (handler) => + listen("mobile-session-read-request", (event) => + handler(event.payload) + ), + reply: (requestId, visitedIds) => + invoke("mobile_remote_reply_read_state", { requestId, visitedIds }), + notifyChanged: () => invoke("mobile_remote_read_state_changed"), + read: () => store.get(visitedSessionsAtom), + mark: markAllSessionsVisitedPersisted, + subscribe: (handler) => store.sub(visitedSessionsAtom, handler), + onError: () => + logger.warn( + "Read-state synchronization failed; will reconcile on the next request" + ), + }); + }, [enabled]); +} diff --git a/src/components/MarkDown/CodeBlockToolbar.test.ts b/src/components/MarkDown/CodeBlockToolbar.test.ts new file mode 100644 index 0000000000..c8a72a8b1c --- /dev/null +++ b/src/components/MarkDown/CodeBlockToolbar.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +import { CodeBlockToolbar } from "./CodeBlockToolbar"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe("shared code block toolbar", () => { + it("preserves Desktop copy/open actions while touch mode gates pending copy", async () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const onCopy = vi.fn(); + const onOpen = vi.fn(); + try { + await act(async () => + root.render( + React.createElement(CodeBlockToolbar, { + copyLabel: "Copy", + copied: false, + onCopy, + openLabel: "Open", + onOpen, + }) + ) + ); + await act(async () => { + ( + host.querySelector('[aria-label="Open"]') as HTMLButtonElement + ).click(); + ( + host.querySelector('[aria-label="Copy"]') as HTMLButtonElement + ).click(); + }); + expect(onCopy).toHaveBeenCalledTimes(1); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(host.querySelector("[data-touch]")).toBeNull(); + await act(async () => + root.render( + React.createElement(CodeBlockToolbar, { + touch: true, + pending: true, + copyLabel: "Copying", + copied: false, + onCopy, + }) + ) + ); + expect(host.querySelector("[data-touch]")).not.toBeNull(); + expect(host.querySelector('[aria-label="Open"]')).toBeNull(); + await act(async () => + (host.querySelector("button") as HTMLButtonElement).click() + ); + expect(onCopy).toHaveBeenCalledTimes(1); + } finally { + await act(async () => root.unmount()); + host.remove(); + } + }); +}); diff --git a/src/components/MarkDown/CodeBlockToolbar.tsx b/src/components/MarkDown/CodeBlockToolbar.tsx new file mode 100644 index 0000000000..f00897fe69 --- /dev/null +++ b/src/components/MarkDown/CodeBlockToolbar.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import Button from "@src/components/Button"; +import { + Copy01Icon, + HugeiconsIcon, + SquareArrowUpRight02Icon, + Tick01Icon, +} from "@src/icons"; + +export interface CodeBlockToolbarProps { + copyLabel: string; + copied: boolean; + onCopy: () => void; + pending?: boolean; + touch?: boolean; + openLabel?: string; + onOpen?: () => void; +} + +/** Browser-safe chrome; each shell owns clipboard and file-opening actions. */ +export function CodeBlockToolbar({ + copyLabel, + copied, + onCopy, + pending, + touch, + openLabel, + onOpen, +}: CodeBlockToolbarProps) { + const buttonClass = `hover:bg-fill-3 hover:text-text-1 focus-visible:ring-2 focus-visible:ring-primary-6/30 focus-visible:outline-none ${touch ? "h-11 w-11" : ""}`; + return ( +
+ {onOpen && ( +
+ ); +} diff --git a/src/components/MarkDown/MarkdownCodeBlock.tsx b/src/components/MarkDown/MarkdownCodeBlock.tsx index d91bed8b0c..f14d4a560d 100644 --- a/src/components/MarkDown/MarkdownCodeBlock.tsx +++ b/src/components/MarkDown/MarkdownCodeBlock.tsx @@ -13,17 +13,11 @@ import React, { Suspense, lazy, memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import Button from "@src/components/Button"; import { useCopyCheck } from "@src/hooks/ui/useCopyCheck"; -import { - Copy01Icon, - HugeiconsIcon, - SquareArrowUpRight02Icon, - Tick01Icon, -} from "@src/icons"; import { copyText } from "@src/util/data/clipboard"; import { openFileInWorkStation } from "@src/util/ui/openFileInWorkStation"; +import { CodeBlockToolbar } from "./CodeBlockToolbar"; import { MarkdownFallbackBoundary } from "./MarkdownFallbackBoundary"; const MarkdownCodeHighlighter = lazy( @@ -91,57 +85,13 @@ const CodeBlock = memo( return (
-
- {openFilePath && ( -
+ void; /** Open the detailed Effort/Speed rows instead of the compact slider. */ defaultAdvanced?: boolean; + className?: string; renderTrigger: (props: { open: boolean; onClick: React.MouseEventHandler; @@ -74,6 +75,7 @@ export default function ModelSettingsMenu({ onModelClick, onChange, defaultAdvanced = false, + className = "", renderTrigger, }: ModelSettingsMenuProps) { const { t } = useTranslation(); @@ -178,7 +180,7 @@ export default function ModelSettingsMenu({ role="dialog" tabIndex={-1} aria-label={text("settings")} - className={DROPDOWN_WIDTHS.fixedStatusPanelClass} + className={`${DROPDOWN_WIDTHS.fixedStatusPanelClass} ${className}`} style={{ position: "fixed", top: panelPosition.top, diff --git a/src/components/ModelSelectorPill/index.tsx b/src/components/ModelSelectorPill/index.tsx index 652b2d1a36..b68589f483 100644 --- a/src/components/ModelSelectorPill/index.tsx +++ b/src/components/ModelSelectorPill/index.tsx @@ -62,6 +62,8 @@ interface ModelSelectorPillProps { effortSegmentOverride?: ModelEffortSegmentState; /** Mobile opens the detailed Effort/Speed menu instead of the slider. */ settingsMenuDefaultAdvanced?: boolean; + /** Portal surface styling for constrained hosts such as Mobile Remote. */ + settingsMenuClassName?: string; /** Mobile uses the combined settings menu whenever variant rows exist. */ preferCombinedSettingsMenu?: boolean; /** Prevent opening a picker while its execution inventory is unresolved. */ @@ -88,6 +90,7 @@ const ModelSelectorPill = forwardRef( isActiveSession = false, effortSegmentOverride, settingsMenuDefaultAdvanced = false, + settingsMenuClassName, preferCombinedSettingsMenu = false, disabled = false, disabledTooltip, @@ -310,6 +313,7 @@ const ModelSelectorPill = forwardRef( onModelClick={onClick} onChange={handleEffortApply} defaultAdvanced={settingsMenuDefaultAdvanced} + className={settingsMenuClassName} renderTrigger={({ open, onClick: openMenu, previewLevel }) => { // While the effort slider is dragged the pill reports the level // under the thumb, so the panel is not the only place showing diff --git a/src/components/PermissionPrompt/PermissionSheet.test.ts b/src/components/PermissionPrompt/PermissionSheet.test.ts new file mode 100644 index 0000000000..a806c2789b --- /dev/null +++ b/src/components/PermissionPrompt/PermissionSheet.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { PermissionSheet } from "./PermissionSheet"; + +vi.mock("@src/components/BottomSheet", () => ({ + default: ({ + children, + footer, + }: { + children: React.ReactNode; + footer: React.ReactNode; + }) => React.createElement("section", null, children, footer), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("PermissionSheet request fidelity", () => { + it("shows a failed decision inside the sheet without disabling retry", () => { + const host = document.createElement("div"); + host.innerHTML = renderToStaticMarkup( + React.createElement(PermissionSheet, { + open: true, + request: { + sessionId: "s", + requestId: "p", + toolName: "shell", + toolArgs: {}, + }, + error: "Could not submit. Try again.", + onAllow: vi.fn(), + onDeny: vi.fn(), + onAlwaysAllow: vi.fn(), + }) + ); + expect(host.querySelector('[role="alert"]')?.textContent).toBe( + "Could not submit. Try again." + ); + expect(host.querySelectorAll("button:disabled")).toHaveLength(0); + }); + it.each([false, true])( + "disables decisions only when request detail is incomplete (%s)", + (truncated) => { + const host = document.createElement("div"); + host.innerHTML = renderToStaticMarkup( + React.createElement(PermissionSheet, { + open: true, + request: { + sessionId: "s", + requestId: "p", + toolName: "run_shell", + toolArgs: { command: "pwd" }, + toolArgsTruncated: truncated, + }, + notice: truncated + ? "Check the complete request on Desktop" + : undefined, + onAllow: vi.fn(), + onDeny: vi.fn(), + onAlwaysAllow: vi.fn(), + }) + ); + expect(host.querySelectorAll("button")).toHaveLength(3); + expect(host.querySelectorAll("button:disabled")).toHaveLength( + truncated ? 3 : 0 + ); + expect(host.querySelector('[role="status"]') != null).toBe(truncated); + } + ); +}); diff --git a/src/components/PermissionPrompt/PermissionSheet.tsx b/src/components/PermissionPrompt/PermissionSheet.tsx index a7919ce312..2d1f136a86 100644 --- a/src/components/PermissionPrompt/PermissionSheet.tsx +++ b/src/components/PermissionPrompt/PermissionSheet.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import BottomSheet from "@src/components/BottomSheet"; import Button from "@src/components/Button"; +import InlineAlert from "@src/components/PageNotice"; import { HugeiconsIcon, NotificationBubbleIcon } from "@src/icons"; import { PermissionPromptActions } from "./PermissionPromptActions"; @@ -21,6 +22,7 @@ export interface PermissionSheetRequest { toolName: string; toolCallId?: string; toolArgs: Record; + toolArgsTruncated?: boolean; origin?: "rust_agent" | "cli_hook" | "acp"; } @@ -30,6 +32,8 @@ export interface PermissionSheetProps { desktopName?: string; queueDepth?: number; submitting?: boolean; + notice?: string; + error?: string; onDeny: () => void; onAllow: () => void; onAlwaysAllow: () => void; @@ -48,6 +52,8 @@ export function PermissionSheet({ desktopName, queueDepth = 0, submitting = false, + notice, + error, onDeny, onAllow, onAlwaysAllow, @@ -104,7 +110,7 @@ export function PermissionSheet({ footer={ + {notice ? ( +

+ {notice} +

+ ) : null} + {error ? ( + + {error} + + ) : null} {onDismiss ? (
-
- {!listOpen && currentTimeLabel ? ( - +
+ {!isMobile && !listOpen && currentTimeLabel ? ( + {currentTimeLabel} ) : null} @@ -324,6 +346,14 @@ const TurnNavigationToolbar: React.FC = memo( )}
+ {isMobile && statusAnnotation ? ( + + {statusAnnotation} + + ) : null} ); } diff --git a/src/features/CodeMirror/shared/mergeTheme.ts b/src/features/CodeMirror/shared/mergeTheme.ts new file mode 100644 index 0000000000..eb076e0b49 --- /dev/null +++ b/src/features/CodeMirror/shared/mergeTheme.ts @@ -0,0 +1,61 @@ +import { EditorView } from "@codemirror/view"; + +// ============================================ +// Shared merge theme override (stable reference — defined outside component) +// ============================================ + +export const MERGE_THEME_OVERRIDE = EditorView.baseTheme({ + "& .cm-changedLine, & .cm-insertedLine": { + backgroundColor: "var(--diff-added-bg) !important", + }, + "&.cm-merge-a .cm-changedLine, & .cm-deletedLine": { + backgroundColor: "var(--diff-deleted-bg) !important", + }, + "& .cm-insertedChunk, & .cm-insertedText": { + backgroundColor: "var(--diff-added-bg) !important", + }, + "& .cm-deletedChunk, & .cm-deletedText": { + backgroundColor: "var(--diff-deleted-bg) !important", + }, + ".cm-collapsedLines": { + display: "flex", + alignItems: "center", + gap: "var(--cm-gutter-padding, 4px)", + width: "100%", + background: "var(--color-fill-1)", + border: "none", + borderRadius: "0", + outline: "none", + boxShadow: "none", + color: "var(--color-text-3)", + padding: "var(--cm-gutter-padding, 4px) var(--cm-line-padding-left, 12px)", + margin: "0", + cursor: "var(--interactive-cursor, default)", + fontSize: "var(--cm-font-size-small, 12px)", + "&::before": { + content: '""', + display: "inline-block", + width: "var(--cm-icon-size, 14px)", + height: "var(--cm-icon-size, 14px)", + marginInlineEnd: "0", + backgroundColor: "currentColor", + maskImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E")`, + maskSize: "contain", + maskRepeat: "no-repeat", + maskPosition: "center", + WebkitMaskImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 15 5 5 5-5'/%3E%3Cpath d='m7 9 5-5 5 5'/%3E%3C/svg%3E")`, + WebkitMaskSize: "contain", + WebkitMaskRepeat: "no-repeat", + WebkitMaskPosition: "center", + flexShrink: 0, + }, + "&::after": { + content: '""', + display: "none", + }, + "&:hover": { + background: "var(--color-fill-3)", + color: "var(--color-text-2)", + }, + }, +}); diff --git a/src/features/CodeMirror/themes/github.test.ts b/src/features/CodeMirror/themes/github.test.ts new file mode 100644 index 0000000000..7da1eb8da5 --- /dev/null +++ b/src/features/CodeMirror/themes/github.test.ts @@ -0,0 +1,19 @@ +import { highlightingFor } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { tags } from "@lezer/highlight"; +import { describe, expect, it } from "vitest"; + +import { createGithubTheme } from "./github"; + +describe("shared CodeMirror diff theme", () => { + it("maps inserted, deleted and hunk tokens to distinct highlight classes", () => { + const state = EditorState.create({ extensions: [createGithubTheme()] }); + const inserted = highlightingFor(state, [tags.inserted]); + const deleted = highlightingFor(state, [tags.deleted]); + const hunk = highlightingFor(state, [tags.meta]); + expect(inserted).toBeTruthy(); + expect(deleted).toBeTruthy(); + expect(hunk).toBeTruthy(); + expect(new Set([inserted, deleted, hunk]).size).toBe(3); + }); +}); diff --git a/src/features/CodeMirror/themes/github.ts b/src/features/CodeMirror/themes/github.ts index 48bd404f2b..f20f41073d 100644 --- a/src/features/CodeMirror/themes/github.ts +++ b/src/features/CodeMirror/themes/github.ts @@ -39,6 +39,7 @@ export function createGithubTheme(): ReturnType { const invalid = cssVar("--cm-syntax-invalid", "#cb2431"); const deleted = cssVar("--cm-syntax-deleted", "#b31d28"); const deletedBg = cssVar("--cm-syntax-deleted-bg", "#ffeef0"); + const insertedBg = cssVar("--diff-added-bg", "#e6ffed"); const styles = [ { tag: [t.standard(t.tagName), t.tagName], color: tag }, @@ -54,6 +55,7 @@ export function createGithubTheme(): ReturnType { { tag: [t.heading, t.strong], color: func, fontWeight: "bold" }, { tag: [t.emphasis], color: func, fontStyle: "italic" }, { tag: [t.deleted], color: deleted, backgroundColor: deletedBg }, + { tag: [t.inserted], color: tag, backgroundColor: insertedBg }, { tag: [t.atom, t.bool, t.special(t.variableName)], color: constant }, { tag: [t.url, t.escape, t.regexp, t.link], color: link }, { tag: t.link, textDecoration: "underline" }, diff --git a/src/hooks/voice/nativeSpeech.test.ts b/src/hooks/voice/nativeSpeech.test.ts new file mode 100644 index 0000000000..b7118f7488 --- /dev/null +++ b/src/hooks/voice/nativeSpeech.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isNativeIosSpeechRuntime } from "./nativeSpeech"; + +vi.mock("@src/util/platform/isIOS", () => ({ + isIOS: () => false, +})); + +describe("isNativeIosSpeechRuntime", () => { + const originalBuildFlag = process.env.ORGII_MOBILE_REMOTE_NATIVE; + + afterEach(() => { + if (originalBuildFlag === undefined) { + delete process.env.ORGII_MOBILE_REMOTE_NATIVE; + } else { + process.env.ORGII_MOBILE_REMOTE_NATIVE = originalBuildFlag; + } + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; + }); + + it("uses the native speech path for the dedicated iOS bundle even when the webview user agent is desktop-like", () => { + process.env.ORGII_MOBILE_REMOTE_NATIVE = "true"; + ( + window as unknown as { + __TAURI_INTERNALS__?: Record; + } + ).__TAURI_INTERNALS__ = {}; + + expect(isNativeIosSpeechRuntime()).toBe(true); + }); +}); diff --git a/src/hooks/voice/nativeSpeech.ts b/src/hooks/voice/nativeSpeech.ts new file mode 100644 index 0000000000..105a35a972 --- /dev/null +++ b/src/hooks/voice/nativeSpeech.ts @@ -0,0 +1,60 @@ +import { + type PluginListener, + addPluginListener, + invoke, +} from "@tauri-apps/api/core"; + +import { isIOS } from "@src/util/platform/isIOS"; + +export type NativeSpeechEventKind = + | "started" + | "partial" + | "final" + | "ended" + | "cancelled" + | "error"; + +export interface NativeSpeechEvent { + sessionId: string; + kind: NativeSpeechEventKind; + transcript?: string; + code?: string; + message?: string; +} + +interface NativeSpeechSupport { + supported: boolean; +} + +export function isNativeIosSpeechRuntime(): boolean { + return ( + typeof window !== "undefined" && + (process.env.ORGII_MOBILE_REMOTE_NATIVE === "true" || isIOS()) && + ("__TAURI_INTERNALS__" in window || "__TAURI__" in window) + ); +} + +export async function queryNativeSpeechSupport(): Promise { + const response = await invoke( + "plugin:speech|is_supported" + ); + return response.supported; +} + +export function listenToNativeSpeech( + callback: (event: NativeSpeechEvent) => void +): Promise { + return addPluginListener("speech", "speech", callback); +} + +export function startNativeSpeech(lang: string, sessionId: string) { + return invoke("plugin:speech|start", { lang, sessionId }); +} + +export function stopNativeSpeech(sessionId: string) { + return invoke("plugin:speech|stop", { sessionId }); +} + +export function cancelNativeSpeech(sessionId: string) { + return invoke("plugin:speech|cancel", { sessionId }); +} diff --git a/src/hooks/voice/useVoiceInput.native.test.ts b/src/hooks/voice/useVoiceInput.native.test.ts new file mode 100644 index 0000000000..75e6571fa1 --- /dev/null +++ b/src/hooks/voice/useVoiceInput.native.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { type UseVoiceInputResult, useVoiceInput } from "./useVoiceInput"; + +const native = vi.hoisted(() => ({ + callback: undefined as + | ((event: { + sessionId: string; + kind: string; + transcript?: string; + }) => void) + | undefined, + cancel: vi.fn().mockResolvedValue(undefined), + querySupport: vi.fn().mockResolvedValue(true), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + unregister: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./nativeSpeech", () => ({ + isNativeIosSpeechRuntime: () => true, + queryNativeSpeechSupport: native.querySupport, + listenToNativeSpeech: vi.fn((callback: typeof native.callback) => { + native.callback = callback; + return Promise.resolve({ unregister: native.unregister }); + }), + startNativeSpeech: native.start, + stopNativeSpeech: native.stop, + cancelNativeSpeech: native.cancel, +})); + +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useVoiceInput native iOS speech", () => { + beforeEach(() => { + native.callback = undefined; + native.cancel.mockClear(); + native.querySupport.mockClear(); + native.querySupport.mockResolvedValue(true); + native.start.mockClear(); + native.start.mockResolvedValue(undefined); + native.stop.mockClear(); + native.stop.mockResolvedValue(undefined); + native.cancel.mockResolvedValue(undefined); + native.unregister.mockClear(); + native.unregister.mockResolvedValue(undefined); + }); + + it("streams native partial text and commits the stopped session", async () => { + let voice: UseVoiceInputResult | undefined; + const onCommit = vi.fn(); + const root = createSmokeRoot(); + + function Probe() { + const value = useVoiceInput({ lang: "zh-CN", onCommit }); + React.useEffect(() => { + voice = value; + }, [value]); + return null; + } + + await root.render(React.createElement(Probe)); + act(() => voice?.start()); + await flushAsync(); + + expect(native.start).toHaveBeenCalledOnce(); + const sessionId = native.start.mock.calls[0]?.[1] as string; + act(() => native.callback?.({ sessionId, kind: "started" })); + act(() => + native.callback?.({ + sessionId, + kind: "partial", + transcript: "手机语音正常", + }) + ); + + expect(voice?.isRecording).toBe(true); + expect(voice?.liveTranscript).toBe("手机语音正常"); + act(() => voice?.stop()); + expect(native.stop).toHaveBeenCalledWith(sessionId); + act(() => + native.callback?.({ + sessionId, + kind: "ended", + transcript: "手机语音正常", + }) + ); + + expect(onCommit).toHaveBeenCalledWith("手机语音正常"); + expect(voice?.isRecording).toBe(false); + await root.unmount(); + expect(native.unregister).toHaveBeenCalledOnce(); + }); + + it("discards callbacks from a cancelled native session", async () => { + let voice: UseVoiceInputResult | undefined; + const onCommit = vi.fn(); + const onCancel = vi.fn(); + const root = createSmokeRoot(); + + function Probe() { + const value = useVoiceInput({ onCommit, onCancel }); + React.useEffect(() => { + voice = value; + }, [value]); + return null; + } + + await root.render(React.createElement(Probe)); + act(() => voice?.start()); + await flushAsync(); + const sessionId = native.start.mock.calls[0]?.[1] as string; + act(() => native.callback?.({ sessionId, kind: "started" })); + act(() => voice?.cancel()); + act(() => + native.callback?.({ sessionId, kind: "ended", transcript: "stale" }) + ); + + expect(native.cancel).toHaveBeenCalledWith(sessionId); + expect(onCancel).toHaveBeenCalledOnce(); + expect(onCommit).not.toHaveBeenCalled(); + await root.unmount(); + }); + it("handles native cleanup rejection and ignores late transcripts after unmount", async () => { + let voice: UseVoiceInputResult | undefined; + const onCommit = vi.fn(); + const root = createSmokeRoot(); + function Probe() { + const value = useVoiceInput({ onCommit }); + React.useEffect(() => { + voice = value; + }, [value]); + return null; + } + await root.render(React.createElement(Probe)); + act(() => voice?.start()); + await flushAsync(); + const sessionId = native.start.mock.calls[0]?.[1] as string; + native.cancel.mockRejectedValueOnce(new Error("Native cancel unavailable")); + native.unregister.mockRejectedValueOnce( + new Error("Listener already closed") + ); + await root.unmount(); + await flushAsync(); + native.callback?.({ sessionId, kind: "ended", transcript: "late" }); + expect(native.cancel).toHaveBeenCalledWith(sessionId); + expect(native.unregister).toHaveBeenCalledOnce(); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/voice/useVoiceInput.ts b/src/hooks/voice/useVoiceInput.ts index 10ded8ce23..b80bc1477e 100644 --- a/src/hooks/voice/useVoiceInput.ts +++ b/src/hooks/voice/useVoiceInput.ts @@ -1,5 +1,5 @@ /** - * useVoiceInput — push-to-talk dictation hook backed by the Web Speech API. + * useVoiceInput — push-to-talk dictation for browser/desktop and native iOS. * * Voice input: user clicks the mic (or hits ⌃M) to start, sees a * waveform UI while speaking, then stops to accept (transcription is committed @@ -7,14 +7,22 @@ * yields plain transcript strings; wiring into a contenteditable / ComposerInput host * lives in the consumer. * - * Transcription runs entirely in the Chromium webview via `webkitSpeechRecognition` - * (no Tauri / backend dependency). Network access is still required because - * Chromium streams audio to Google's recognition endpoint. + * Chromium uses `webkitSpeechRecognition`. ORG2 Remote uses the iOS Speech + * framework through a Tauri plugin because WKWebView does not expose that API. */ import { useCallback, useEffect, useRef, useState } from "react"; import { createLogger } from "@src/hooks/logger"; +import { + type NativeSpeechEvent, + cancelNativeSpeech, + isNativeIosSpeechRuntime, + listenToNativeSpeech, + queryNativeSpeechSupport, + startNativeSpeech, + stopNativeSpeech, +} from "./nativeSpeech"; import { mapGetUserMediaError, queryMicrophonePermission, @@ -128,7 +136,7 @@ export interface UseVoiceInputOptions { export interface UseVoiceInputResult { /** True while microphone is capturing audio. */ isRecording: boolean; - /** True if the Web Speech API is available in this browser. */ + /** True if the current runtime provides a speech recognizer. */ isSupported: boolean; /** Live partial transcript while speaking (resets when recording stops). */ liveTranscript: string; @@ -148,7 +156,10 @@ function mapErrorCode(raw: string): VoiceInputErrorCode { switch (raw) { case "not-allowed": case "service-not-allowed": + case "permission-denied": return "permission-denied"; + case "unsupported": + return "unsupported"; case "no-speech": return "no-speech"; case "audio-capture": @@ -156,18 +167,41 @@ function mapErrorCode(raw: string): VoiceInputErrorCode { case "network": return "network"; case "aborted": + case "cancelled": return "aborted"; default: return "unknown"; } } +function mapNativeFailure(error: unknown): VoiceInputError { + const value = + error && typeof error === "object" + ? (error as { code?: unknown; message?: unknown }) + : undefined; + const rawCode = typeof value?.code === "string" ? value.code : "unknown"; + const message = + typeof value?.message === "string" ? value.message : String(error); + return { code: mapErrorCode(rawCode), message }; +} + export function useVoiceInput( options: UseVoiceInputOptions ): UseVoiceInputResult { const { lang, onCommit, onCancel, onError } = options; + const nativeRuntime = isNativeIosSpeechRuntime(); const recognitionRef = useRef(null); + const nativeSessionRef = useRef(null); + const nativeListenerRef = useRef + > | null>(null); + const nativeListenerPromiseRef = useRef | null>(null); + const nativeEventHandlerRef = useRef<(event: NativeSpeechEvent) => void>( + () => undefined + ); const transcriptRef = useRef(""); // When cancel() is called we still receive an `onend` event from the // recognizer; this flag tells the end handler whether to commit or discard. @@ -175,13 +209,17 @@ export function useVoiceInput( const startTimeRef = useRef(0); const tickIntervalRef = useRef(null); const startSessionRef = useRef(0); + const startPendingRef = useRef(false); + const callbacksRef = useRef({ onCommit, onCancel, onError }); + callbacksRef.current = { onCommit, onCancel, onError }; const [isRecording, setIsRecording] = useState(false); const [liveTranscript, setLiveTranscript] = useState(""); const [elapsedSeconds, setElapsedSeconds] = useState(0); - const [isSupported] = useState(() => { + const [isSupported, setIsSupported] = useState(() => { probeSpeechRecognitionOnce(); + if (nativeRuntime) return true; // Tauri dev shows a preview UI even without a native recognizer. if (isTauriRuntime() && !isTauriProduction()) { return true; @@ -196,8 +234,20 @@ export function useVoiceInput( } }, []); + const startTimer = useCallback(() => { + clearTimer(); + startTimeRef.current = Date.now(); + setIsRecording(true); + setElapsedSeconds(0); + tickIntervalRef.current = window.setInterval(() => { + const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); + setElapsedSeconds(elapsed); + }, 250); + }, [clearTimer]); + const teardown = useCallback(() => { clearTimer(); + startPendingRef.current = false; setIsRecording(false); setElapsedSeconds(0); setLiveTranscript(""); @@ -211,6 +261,57 @@ export function useVoiceInput( } }, [clearTimer]); + nativeEventHandlerRef.current = (event) => { + if (event.sessionId !== nativeSessionRef.current) return; + + if (event.kind === "started") { + startPendingRef.current = false; + startTimer(); + return; + } + if (event.kind === "partial" || event.kind === "final") { + const transcript = event.transcript?.trim() ?? ""; + transcriptRef.current = transcript; + setLiveTranscript(transcript); + return; + } + + const transcript = (event.transcript ?? transcriptRef.current).trim(); + const commit = shouldCommitRef.current; + nativeSessionRef.current = null; + teardown(); + + if (event.kind === "error") { + callbacksRef.current.onError?.({ + code: mapErrorCode(event.code ?? "unknown"), + message: event.message ?? "Speech recognition failed.", + }); + callbacksRef.current.onCancel?.(); + } else if (event.kind === "cancelled" || !commit) { + callbacksRef.current.onCancel?.(); + } else if (event.kind === "ended" && transcript.length > 0) { + callbacksRef.current.onCommit(transcript); + } + }; + + const ensureNativeListener = useCallback(async () => { + if (nativeListenerRef.current) return nativeListenerRef.current; + if (nativeListenerPromiseRef.current) { + return nativeListenerPromiseRef.current; + } + const pending = listenToNativeSpeech((event) => { + nativeEventHandlerRef.current(event); + }); + nativeListenerPromiseRef.current = pending; + try { + const listener = await pending; + nativeListenerRef.current = listener; + return listener; + } finally { + nativeListenerPromiseRef.current = null; + } + }, []); + const beginRecognition = useCallback( (Ctor: NonNullable>) => { const recognition = new Ctor(); @@ -226,15 +327,7 @@ export function useVoiceInput( setLiveTranscript(""); recognition.onstart = () => { - startTimeRef.current = Date.now(); - setIsRecording(true); - setElapsedSeconds(0); - tickIntervalRef.current = window.setInterval(() => { - const elapsed = Math.floor( - (Date.now() - startTimeRef.current) / 1000 - ); - setElapsedSeconds(elapsed); - }, 250); + startTimer(); logger.debug("recognition started", { lang: recognition.lang }); }; @@ -259,7 +352,10 @@ export function useVoiceInput( const code = mapErrorCode(event.error); logger.warn("recognition error", event.error, event.message); shouldCommitRef.current = false; - onError?.({ code, message: event.message || event.error }); + callbacksRef.current.onError?.({ + code, + message: event.message || event.error, + }); }; recognition.onend = () => { @@ -268,9 +364,9 @@ export function useVoiceInput( logger.debug("recognition ended", { commit, length: final.length }); teardown(); if (commit && final.length > 0) { - onCommit(final); + callbacksRef.current.onCommit(final); } else if (!commit) { - onCancel?.(); + callbacksRef.current.onCancel?.(); } }; @@ -296,27 +392,53 @@ export function useVoiceInput( : ""; const message = err instanceof Error ? err.message : String(err); logger.error("failed to start recognition", { name, message, err }); - onError?.({ + callbacksRef.current.onError?.({ code: "unknown", message, }); teardown(); } }, - [lang, onCancel, onCommit, onError, teardown] + [lang, startTimer, teardown] ); const start = useCallback(() => { - if (isRecording) return; + if (isRecording || startPendingRef.current) return; + + if (nativeRuntime) { + const sessionId = `speech-${Date.now()}-${startSessionRef.current + 1}`; + startSessionRef.current += 1; + nativeSessionRef.current = sessionId; + startPendingRef.current = true; + shouldCommitRef.current = true; + transcriptRef.current = ""; + setLiveTranscript(""); + + void (async () => { + try { + await ensureNativeListener(); + if (nativeSessionRef.current !== sessionId) return; + const detectedLang = + lang ?? + (typeof navigator !== "undefined" + ? navigator.language + : undefined) ?? + "en-US"; + await startNativeSpeech(detectedLang, sessionId); + } catch (error) { + if (nativeSessionRef.current !== sessionId) return; + nativeSessionRef.current = null; + const failure = mapNativeFailure(error); + logger.warn("native speech start failed", failure); + teardown(); + callbacksRef.current.onError?.(failure); + } + })().catch((error) => logger.warn("Background operation failed", error)); + return; + } if (isTauriRuntime() && !isTauriProduction()) { - startTimeRef.current = Date.now(); - setIsRecording(true); - setElapsedSeconds(0); - tickIntervalRef.current = window.setInterval(() => { - const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000); - setElapsedSeconds(elapsed); - }, 250); + startTimer(); void showVoiceInputDevModeDialog(); return; } @@ -328,7 +450,7 @@ export function useVoiceInput( message: "Speech recognition is not available in this environment.", }; logger.warn(err.message); - onError?.(err); + callbacksRef.current.onError?.(err); return; } @@ -347,7 +469,7 @@ export function useVoiceInput( const permissionState = await queryMicrophonePermission(); if (startSessionRef.current !== sessionId) return; if (permissionState === "denied") { - onError?.({ + callbacksRef.current.onError?.({ code: "permission-denied", message: "Microphone permission denied.", }); @@ -362,14 +484,14 @@ export function useVoiceInput( if (startSessionRef.current !== sessionId) return; const access = mapGetUserMediaError(err); if (access === "denied") { - onError?.({ + callbacksRef.current.onError?.({ code: "permission-denied", message: "Microphone permission denied.", }); return; } if (access === "unsupported") { - onError?.({ + callbacksRef.current.onError?.({ code: "audio-capture", message: "No microphone detected.", }); @@ -380,10 +502,34 @@ export function useVoiceInput( if (startSessionRef.current !== sessionId) return; beginRecognition(Ctor); - })(); - }, [beginRecognition, isRecording, onError]); + })().catch((error) => { + if (startSessionRef.current !== sessionId) return; + teardown(); + callbacksRef.current.onError?.(mapNativeFailure(error)); + }); + }, [ + beginRecognition, + ensureNativeListener, + isRecording, + lang, + nativeRuntime, + startTimer, + teardown, + ]); const stop = useCallback(() => { + const nativeSessionId = nativeSessionRef.current; + if (nativeSessionId) { + shouldCommitRef.current = true; + void stopNativeSpeech(nativeSessionId).catch((error) => { + if (nativeSessionRef.current !== nativeSessionId) return; + nativeSessionRef.current = null; + const failure = mapNativeFailure(error); + teardown(); + callbacksRef.current.onError?.(failure); + }); + return; + } if (!recognitionRef.current) { if (!isRecording) { startSessionRef.current += 1; @@ -402,6 +548,17 @@ export function useVoiceInput( }, [isRecording, teardown]); const cancel = useCallback(() => { + const nativeSessionId = nativeSessionRef.current; + if (nativeSessionId) { + nativeSessionRef.current = null; + shouldCommitRef.current = false; + teardown(); + callbacksRef.current.onCancel?.(); + void cancelNativeSpeech(nativeSessionId).catch((error) => { + logger.warn("native speech cancel failed", error); + }); + return; + } if (!recognitionRef.current) { if (!isRecording) { startSessionRef.current += 1; @@ -430,7 +587,29 @@ export function useVoiceInput( }, [isRecording, start, stop]); useEffect(() => { + if (!nativeRuntime) return; + let disposed = false; + void queryNativeSpeechSupport() + .then((supported) => { + if (!disposed) setIsSupported(supported); + }) + .catch((error) => { + logger.warn("native speech support probe failed", error); + if (!disposed) setIsSupported(false); + }); return () => { + disposed = true; + }; + }, [nativeRuntime]); + + useEffect(() => { + return () => { + const nativeSessionId = nativeSessionRef.current; + nativeSessionRef.current = null; + if (nativeSessionId) + void cancelNativeSpeech(nativeSessionId).catch((error) => + logger.warn("Background operation failed", error) + ); if (recognitionRef.current) { shouldCommitRef.current = false; try { @@ -440,6 +619,20 @@ export function useVoiceInput( } } clearTimer(); + const listener = nativeListenerRef.current; + nativeListenerRef.current = null; + if (listener) + void listener + .unregister() + .catch((error) => logger.warn("Background operation failed", error)); + const pendingListener = nativeListenerPromiseRef.current; + if (pendingListener) { + void pendingListener + .then((value) => value.unregister()) + .catch((error) => + logger.warn("native listener cleanup failed", error) + ); + } }; }, [clearTimer]); diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index 9563a6eedb..3a8dd0fb1f 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Mikrofonzugriff erforderlich" }, "chat": { + "draftTooLong": "Der Entwurf ist zu lang (maximal {{max}} Zeichen)", "cliPermissions": { "ask": "Genehmigung anfordern", "workspace": "Zugriff auf den Arbeitsbereich", diff --git a/src/i18n/locales/en/mobileRemote.json b/src/i18n/locales/en/mobileRemote.json index 20b46d8fb8..27553eaa58 100644 --- a/src/i18n/locales/en/mobileRemote.json +++ b/src/i18n/locales/en/mobileRemote.json @@ -1,12 +1,164 @@ { + "changeReview": { + "scope": "Scope", + "moreActions": "More file actions", + "lines": "Lines {{start}}–{{end}}", + "edit": "Edit {{count}}", + "noNewline": "No newline at end of file", + "rawPatch": "Patch preview unavailable · Original patch", + "showMoreLines": "Show {{count}} more lines", + "filesCount": "{{count}} files changed", + "emptyTurn": "No changes in this turn. Open to review the session", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Copy failed, retry", + "title": "File changes", + "turn": "This turn", + "session": "Session", + "workspace": "Workspace", + "diff": "Diff", + "full": "Full file", + "file": "File", + "before": "Before", + "after": "After", + "reconstructed": "Reconstructed from recorded edits, not the current workspace file", + "expand": "Expand", + "expandAll": "Expand all files", + "collapseAll": "Collapse all files", + "collapse": "Collapse", + "wrap": "Wrap lines", + "retry": "Retry", + "loadFailed": "Couldn't load changes", + "selected": "Selected", + "loading": "Loading…", + "offline": "Desktop is offline", + "empty": "No change records available", + "partial": "Some records are missing full file contents, so changes cannot be fully summarized", + "unavailable": "Full file contents are unavailable for this version" + }, + "fileViewer": { + "files": "Files", + "fileLocation": "File location", + "diff": "Changes", + "patch": "Patch", + "partial": "Partial content", + "copyModified": "Copy new", + "original": "Before", + "modified": "After", + "copyPatch": "Copy patch", + "patchFallback": "Before and after file contents are incomplete or too large. Showing the original patch", + "snapshot": "Read-only", + "wrap": "Wrap lines", + "unavailable": "No file content in this conversation", + "empty": "Empty file", + "openDesktop": "Open on desktop", + "plainTextFallback": "Editor unavailable. Showing plain text" + }, + "search": { + "title": "Search sessions", + "placeholder": "Session name", + "cancel": "Cancel", + "clear": "Clear search", + "unsupported": "Update ORG2 on your computer to search the complete conversation history", + "offline": "Reconnect to this computer to search", + "loading": "Searching…", + "error": "Could not load these results", + "retry": "Retry search", + "resultsFor": "Results for “{{query}}”", + "empty": "No matching sessions", + "continue": "No visible results on this page. Continue searching", + "next": "Next results" + }, + "inbox": { + "backToSessions": "Back to sessions", + "title": "Needs attention", + "syncing": "Syncing", + "unavailable": "Unavailable", + "error": "Could not update pending requests", + "offline": "Showing last known requests. Reconnect to open and handle them", + "incomplete": "Only some requests are shown. View all requests on your computer", + "empty": "No pending permission requests", + "truncated": "Request details are shortened. View and handle the full request on your computer", + "open": "Open request" + }, + "profile": { + "title": "Profile", + "open": "Open profile", + "close": "Close profile", + "back": "Back to profile", + "fallbackName": "ORG2 user", + "development": "Local development mode", + "helpAndAbout": "Help & about", + "help": "Help", + "terms": "Terms of service", + "danger": "Danger zone", + "deleteHint": "Continue to your ORG2 account page to confirm and complete deletion", + "opening": "Opening browser…", + "pairingTitle": "Connect your computer", + "pairingStep1": "Sign in to ORG2 on your computer, then enable Mobile Remote in Settings", + "pairingStep2": "Sign in to the same account on your phone, then scan or paste the desktop pairing code", + "pairingStep3": "Compare the security phrase on both devices and confirm to open your sessions", + "connectionTitle": "Having trouble connecting?", + "connectionHelp": "Keep ORG2 running on your computer and connected to the internet, then check the remote connection status in Settings. Generate a new pairing code if it has expired, or paste the code if scanning is unavailable" + }, + "connection": { + "resolvingSession": "Confirming this session…", + "restoring": "Restoring your device", + "reconnecting": "Reconnecting to your desktop…" + }, + "images": { + "label": "Image {{number}}", + "load": "View image {{number}}", + "loading": "Loading image…", + "retry": "Image unavailable · Retry", + "unavailable": "Connect an updated desktop to view images" + }, + "sessions": { + "all": "All sessions", + "state": { + "unknown": "Status awaiting sync", + "unread": "Completed, unread", + "running": "Running", + "idle": "Idle", + "offline": "Offline", + "awaiting_approval": "Awaiting approval", + "waiting_for_user": "Waiting for your reply", + "pending": "Pending", + "installing": "Installing", + "queued": "Queued", + "in_progress": "In progress", + "waiting_for_funds": "Waiting for funds", + "paused": "Paused", + "completed": "Completed", + "failed": "Failed", + "error": "Error", + "cancelled": "Cancelled", + "abandoned": "Abandoned", + "timeout": "Timed out", + "killed": "Terminated", + "archived": "Archived" + }, + "limit": "Showing up to 1,000 recent sessions", + "viewOptions": "Session view options", + "groupBy": "Group by", + "groupNone": "No grouping", + "groupTime": "Time", + "groupWorkspace": "By workspace", + "groupNoWorkspace": "No workspace", + "groupToday": "Today", + "groupEarlier": "Earlier", + "groupUnknown": "Date unavailable", + "loadMore": "Load more", + "retry": "Retry loading" + }, "auth": { - "title": "Sign in to use Mobile Remote", - "subtitle": "Your ORG2 Cloud identity protects pairing, sessions, and desktop controls", + "title": "Sign in to ORG2", + "subtitle": "Connect your computer and pick up your conversations wherever you are", "checking": "Checking your account", "redirecting": "Opening secure sign-in", "exchanging": "Finishing sign-in", "wait": "This should only take a moment", - "signIn": "Sign in to ORG2 Cloud", + "signIn": "Sign in", "cancel": "Cancel and return to sign-in", "browserWait": "Complete sign-in in your browser, or cancel to try again", "errorTitle": "Sign-in could not be completed", @@ -23,12 +175,12 @@ "scanning": "Point the camera at the pairing QR code on your desktop", "cancelScan": "Cancel scan", "cameraDenied": "Camera access was denied. Allow camera access for ORG2 Remote in Settings, or paste the pairing code", - "cameraUnavailable": "Camera unavailable. In the simulator, paste the pairing code instead", + "cameraUnavailable": "Camera unavailable. Paste the pairing code from your computer", "scanTimeout": "Scan timed out and the camera was closed. Scan again or paste the pairing code", "scanFailed": "Scan failed. Try again or paste the pairing code", - "scanTitle": "Pair with desktop", - "scanHint": "Scan the QR code in desktop Settings or paste its payload. The public relay works over 4G and 5G", - "urlPlaceholder": "Paste the desktop pairing payload, or a ws:// / wss:// URL", + "scanTitle": "Connect your computer", + "scanHint": "Scan the QR code in ORG2 Settings on your computer, or paste the pairing code", + "urlPlaceholder": "Paste a pairing code or connection address", "connect": "Connect", "sasTitle": "Confirm pairing", "sasHint": "Verify the phrase on your desktop screen:", @@ -36,9 +188,9 @@ "connectingTitle": "Connecting", "connectingSubtitle": "Waiting for desktop phrase confirmation and a secure channel…", "errors": { - "empty": "Enter a WebSocket URL or QR payload", - "invalid": "Invalid QR or URL — scan the code from desktop Settings", - "unsupportedVersion": "Unsupported pairing payload version" + "empty": "Enter a pairing code or connection address", + "invalid": "Invalid pairing code or connection address. Get a new one from ORG2 Settings on your computer", + "unsupportedVersion": "This pairing code requires a newer version of ORG2. Update the app and try again" } }, "tabs": { @@ -52,10 +204,11 @@ "thisDeviceLabel": "ORG2 Mobile", "thisDeviceSubtitle": "Full remote · Active now", "pairedDesktops": "Paired desktops", - "primary": "Primary", "online": "Online", "offline": "Offline", "unknown": "Connecting", + "currentDesktop": "Current computer", + "presenceUnknown": "Status unknown", "switchTo": "Switch to {{name}}", "switchFailed": "Could not switch desktops", "emptyDesktops": "No paired desktops" @@ -63,45 +216,49 @@ "settings": { "title": "Settings", "account": "Account", - "signedInAs": "Signed in as", "signOutConfirmTitle": "Sign out?", "signOutConfirmBody": "You will need to sign in again to access your remote computers. Sessions running on your computer will not be stopped", "cancel": "Cancel", - "manageAccount": "Manage Cloud account", - "deleteAccount": "Delete account on Cloud…", + "manageAccount": "Manage account", + "deleteAccount": "Delete account on the web…", "privacyPolicy": "Privacy policy", - "accountWebHint": "Opens the shared Cloud account page, which may require sign-in — check that the email matches the account above before confirming deletion", "openFailed": "Could not open the page — please try again", "signOut": "Sign out", "connection": "Connection", + "authorization": "Device authorization", "desktop": "Desktop", - "relay": "Relay", "permissionTier": "Permission tier", "permissionFull": "Full access", "permissionReadOnly": "Read only", - "mode": "Mode", - "modeDemo": "Demo", - "modeLive": "Live", - "demoBanner": "Demo mode — using local fixtures, not a live desktop", + "demoBanner": "Demo mode — showing sample content, not connected to a computer", "help": "Help", "pairingGuide": "Pairing guide", "revokePairing": "Revoke pairing", "online": "Online", "offline": "Offline", - "unknownRelay": "Not connected", "notAvailable": "—" }, "stopConfirm": { - "failed": "Could not stop the turn. It may still be running. Try again or check the desktop.", - "title": "Stop current Agent turn?", - "body": "Sends a cancel command to the desktop. Whether running shell processes terminate depends on Agent policy", + "title": "Stop the current task?", + "body": "Your computer will be asked to stop the current task. Programs already started may keep running; check your computer to confirm", "cancel": "Cancel", - "confirm": "Stop" + "confirm": "Stop", + "failed": "Could not confirm that the task stopped. Check the connection and try again." + }, + "permission": { + "submitFailed": "Could not confirm your response. Check the connection and try again." + }, + "connectionRecovery": { + "retry": "Reconnect", + "repair": "Pair again", + "retryFailed": "Could not reconnect. Try again.", + "repairFailed": "Could not reset the connection. Try again." }, "offlineBanner": "Desktop is offline — messages cannot be sent", "offlineBannerNamed": "{{desktopName}} is offline — messages cannot be sent", - "composerOffline": "Desktop offline — composer disabled", - "composerReadOnly": "This imported conversation is read-only on mobile", + "composerOffline": "Your computer is offline. Reconnect to send messages", + "composerReadOnly": "This conversation is currently view-only. Messages cannot be sent from your phone", + "composerDeviceReadOnly": "This device has read-only access", "composerSending": "Sending…", "composerAccepted": "Sent — waiting for the Agent…", "composerUncertain": "Connection changed — checking delivery…", @@ -152,14 +309,8 @@ }, "openDetails": "View {{tool}} details", "closeDetails": "Close tool details", - "files": "Files", - "openFile": "Open", "fileOpenRequested": "Sent", - "openFileNamed": "Open {{file}} in Desktop", - "openFileUnavailable": "Connect with full access to open this file in Desktop", "openFileFailed": "Could not open the file ({{message}})", - "changes": "Changes", - "preview": "Preview", "loadingPreview": "Loading highlighted preview", "details": "Details", "output": "Output", @@ -167,14 +318,11 @@ } }, "connectionFailed": "Connection failed", - "sessions": { - "limit": "Showing up to 1,000 recent sessions", - "loadMore": "Load more", - "retry": "Retry loading" - }, "rounds": { + "oldestFirst": "Oldest first ↑", + "newestFirst": "Newest first ↓", "incomplete": "Earlier rounds are not loaded", - "truncated": "Transcript is truncated", + "truncated": "Only part of the conversation history is shown", "navigationLabel": "Round navigation" } } diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 85d8286821..dee7f857d4 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -586,6 +586,7 @@ "full": "Full access", "nextTurn": "Permissions apply to the next turn" }, + "draftTooLong": "Draft is too long (maximum {{max}} characters)", "planApprovedLabel": "Implementing approved plan", "planApprovedEditedLabel": "Implementing approved plan (edited)", "restoreCheckpoint": "Restore checkpoint", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index dfda278855..8d900b1fc5 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Se necesita acceso al micrófono" }, "chat": { + "draftTooLong": "El borrador es demasiado largo (máximo {{max}} caracteres)", "cliPermissions": { "ask": "Solicitar aprobación", "workspace": "Acceso al espacio de trabajo", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index 0388fae02f..b19f1d3a4e 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Accès au microphone requis" }, "chat": { + "draftTooLong": "Le brouillon est trop long ({{max}} caractères maximum)", "cliPermissions": { "ask": "Demander une approbation", "workspace": "Accès à l’espace de travail", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 810c75e311..d534da1b49 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "マイクへのアクセスが必要です" }, "chat": { + "draftTooLong": "下書きが長すぎます(最大 {{max}} 文字)", "cliPermissions": { "ask": "承認を求める", "workspace": "ワークスペースへのアクセス", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 424ef6a52c..df3df3c668 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "마이크 접근 권한 필요" }, "chat": { + "draftTooLong": "초안이 너무 깁니다(최대 {{max}}자)", "cliPermissions": { "ask": "승인 요청", "workspace": "작업 공간 접근", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index b4ff705bfa..55218c5c4b 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Wymagany dostęp do mikrofonu" }, "chat": { + "draftTooLong": "Szkic jest zbyt długi (maksymalnie {{max}} znaków)", "cliPermissions": { "ask": "Proś o zatwierdzenie", "workspace": "Dostęp do obszaru roboczego", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index b40f3890d4..cb13912d3a 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Acesso ao microfone necessário" }, "chat": { + "draftTooLong": "O rascunho é muito longo (máximo de {{max}} caracteres)", "cliPermissions": { "ask": "Solicitar aprovação", "workspace": "Acesso ao espaço de trabalho", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 009e6977f9..6644986740 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Требуется доступ к микрофону" }, "chat": { + "draftTooLong": "Черновик слишком длинный (максимум {{max}} символов)", "cliPermissions": { "ask": "Запрашивать разрешение", "workspace": "Доступ к рабочей области", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index 3563384d47..7734d2dc7f 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Mikrofon erişimi gerekli" }, "chat": { + "draftTooLong": "Taslak çok uzun (en fazla {{max}} karakter)", "cliPermissions": { "ask": "Onay iste", "workspace": "Çalışma alanı erişimi", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index edccfd2d4c..0de61b5114 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "Cần quyền truy cập micrô" }, "chat": { + "draftTooLong": "Bản nháp quá dài (tối đa {{max}} ký tự)", "cliPermissions": { "ask": "Yêu cầu phê duyệt", "workspace": "Truy cập không gian làm việc", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 7f52044f91..c8914d9f00 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -579,6 +579,7 @@ "voicePermissionSheetTitle": "需要麥克風存取權限" }, "chat": { + "draftTooLong": "草稿過長(最多 {{max}} 個字元)", "cliPermissions": { "ask": "操作前審批", "workspace": "工作區權限", diff --git a/src/i18n/locales/zh/mobileRemote.json b/src/i18n/locales/zh/mobileRemote.json index eb0d15ec23..510467923c 100644 --- a/src/i18n/locales/zh/mobileRemote.json +++ b/src/i18n/locales/zh/mobileRemote.json @@ -1,12 +1,164 @@ { + "changeReview": { + "scope": "范围", + "moreActions": "更多文件操作", + "lines": "第 {{start}}–{{end}} 行", + "edit": "编辑 {{count}}", + "noNewline": "文件末尾无换行", + "rawPatch": "暂不支持此补丁预览 · 原始补丁", + "showMoreLines": "再显示 {{count}} 行", + "filesCount": "{{count}} 个文件变更", + "emptyTurn": "本轮没有文件变更,可查看会话累计", + "copy": "复制", + "copied": "已复制", + "copyFailed": "复制失败,重试", + "title": "文件变更", + "turn": "本轮", + "session": "会话累计", + "workspace": "工作区", + "diff": "差异", + "full": "完整文件", + "file": "文件", + "before": "修改前", + "after": "修改后", + "reconstructed": "依据会话编辑记录还原,非当前工作区文件", + "expand": "展开", + "expandAll": "全部展开", + "collapseAll": "全部收起", + "collapse": "收起", + "wrap": "自动换行", + "retry": "重试", + "loadFailed": "暂时无法加载变更", + "selected": "已选择", + "loading": "正在加载…", + "offline": "电脑已离线", + "empty": "没有可用的变更记录", + "partial": "部分记录缺少完整文件内容,暂时无法准确汇总变更", + "unavailable": "无法查看此版本的完整文件" + }, + "fileViewer": { + "files": "文件", + "fileLocation": "文件位置", + "diff": "变更", + "patch": "补丁", + "partial": "部分内容", + "copyModified": "复制新版", + "original": "修改前", + "modified": "修改后", + "copyPatch": "复制补丁", + "patchFallback": "修改前后的文件内容不完整或过大,暂时显示原始补丁", + "snapshot": "只读", + "wrap": "自动换行", + "unavailable": "此会话未包含文件内容", + "empty": "空文件", + "openDesktop": "在电脑上打开", + "plainTextFallback": "编辑器未能加载,已显示纯文本" + }, + "search": { + "title": "搜索会话", + "placeholder": "会话名称", + "cancel": "取消", + "clear": "清空搜索", + "unsupported": "请更新电脑上的 ORG2,以搜索完整会话历史", + "offline": "重新连接这台电脑后可搜索", + "loading": "正在搜索…", + "error": "未能加载这些搜索结果", + "retry": "重试搜索", + "resultsFor": "“{{query}}”的搜索结果", + "empty": "没有匹配的会话", + "continue": "本页没有可显示的结果,可以继续搜索", + "next": "下一页结果" + }, + "inbox": { + "backToSessions": "返回会话", + "title": "待处理", + "syncing": "同步中", + "unavailable": "暂不可用", + "error": "待处理请求更新失败", + "offline": "保留上次已知请求,重新连接后才能打开和处理", + "incomplete": "当前仅显示部分请求,请在电脑上查看全部请求", + "empty": "暂无待批准的权限请求", + "truncated": "请求详情已缩略,请在电脑上查看完整内容并处理", + "open": "打开请求" + }, + "profile": { + "title": "个人资料", + "open": "打开个人资料", + "close": "关闭个人资料", + "back": "返回个人资料", + "fallbackName": "ORG2 用户", + "development": "本地开发模式", + "helpAndAbout": "帮助与关于", + "help": "使用帮助", + "terms": "服务条款", + "danger": "危险操作", + "deleteHint": "前往 ORG2 账号页面确认并完成删除", + "opening": "正在打开浏览器…", + "pairingTitle": "连接你的电脑", + "pairingStep1": "在电脑上登录 ORG2,并在设置中启用移动遥控", + "pairingStep2": "在手机登录相同账号,扫描或粘贴桌面生成的配对码", + "pairingStep3": "核对两端显示的安全短语,确认后进入会话", + "connectionTitle": "连接不上怎么办?", + "connectionHelp": "请保持电脑上的 ORG2 运行并连接网络,再检查设置中的远程连接状态。配对码过期时,请在电脑上重新生成;无法扫码时可以粘贴配对码" + }, + "connection": { + "resolvingSession": "正在确认会话…", + "restoring": "正在恢复设备", + "reconnecting": "正在重新连接桌面…" + }, + "images": { + "label": "图片 {{number}}", + "load": "查看图片 {{number}}", + "loading": "正在加载图片…", + "retry": "图片加载失败 · 重试", + "unavailable": "连接新版桌面端以查看图片" + }, + "sessions": { + "all": "全部会话", + "state": { + "unknown": "状态待同步", + "unread": "已完成,未读", + "running": "运行中", + "idle": "空闲", + "offline": "离线", + "awaiting_approval": "待批准", + "waiting_for_user": "等待回复", + "pending": "待启动", + "installing": "安装中", + "queued": "排队中", + "in_progress": "运行中", + "waiting_for_funds": "等待额度", + "paused": "已暂停", + "completed": "已完成", + "failed": "失败", + "error": "出错", + "cancelled": "已取消", + "abandoned": "已中止", + "timeout": "已超时", + "killed": "已终止", + "archived": "已归档" + }, + "limit": "最多显示最近 1,000 条会话", + "viewOptions": "会话显示选项", + "groupBy": "分组方式", + "groupNone": "不分组", + "groupTime": "按时间", + "groupWorkspace": "按工作区", + "groupNoWorkspace": "未关联工作区", + "groupToday": "今天", + "groupEarlier": "更早", + "groupUnknown": "日期未知", + "loadMore": "加载更多", + "retry": "重试加载" + }, "auth": { - "title": "登录后使用移动遥控", - "subtitle": "使用 ORG2 Cloud 身份保护配对、会话和桌面控制", + "title": "登录 ORG2", + "subtitle": "登录后,连接电脑,随时继续会话", "checking": "正在检查账号", "redirecting": "正在打开安全登录", "exchanging": "正在完成登录", "wait": "这通常只需要一点时间", - "signIn": "登录 ORG2 Cloud", + "signIn": "登录", "cancel": "取消并返回登录", "browserWait": "请在浏览器完成登录,或取消后重新登录", "errorTitle": "无法完成登录", @@ -23,12 +175,12 @@ "scanning": "将电脑上的配对二维码放入画面中", "cancelScan": "取消扫描", "cameraDenied": "未获得相机权限,请在系统设置中允许 ORG2 Remote 使用相机,或粘贴配对码", - "cameraUnavailable": "无法使用相机,模拟器请使用粘贴配对码", + "cameraUnavailable": "无法使用相机,请粘贴电脑上的配对码", "scanTimeout": "扫描已超时,相机已关闭,请重新扫描或粘贴配对码", "scanFailed": "扫描失败,请重试或粘贴配对码", - "scanTitle": "与桌面对齐", - "scanHint": "扫描桌面设置中的二维码,或粘贴二维码载荷文本。公网 Relay 支持手机使用 4G/5G", - "urlPlaceholder": "粘贴桌面生成的配对载荷,或 ws:// / wss:// URL", + "scanTitle": "连接你的电脑", + "scanHint": "扫描电脑上 ORG2 设置中的二维码,或粘贴配对码", + "urlPlaceholder": "粘贴配对码或连接地址", "connect": "连接", "sasTitle": "确认配对", "sasHint": "核对电脑屏幕短语:", @@ -36,9 +188,9 @@ "connectingTitle": "正在连接", "connectingSubtitle": "等待桌面核对安全短语并建立安全通道…", "errors": { - "empty": "请输入 WebSocket URL 或 QR 载荷", - "invalid": "无效二维码或 URL — 请扫描桌面 Settings 中的码", - "unsupportedVersion": "不支持的配对载荷版本" + "empty": "请输入配对码或连接地址", + "invalid": "配对码或连接地址无效,请从电脑上的 ORG2 设置中重新获取", + "unsupportedVersion": "此配对码需要更新版本的 ORG2,请更新后重试" } }, "tabs": { @@ -52,10 +204,11 @@ "thisDeviceLabel": "ORG2 Mobile", "thisDeviceSubtitle": "完整遥控 · 刚刚活跃", "pairedDesktops": "配对的桌面", - "primary": "主桌面", "online": "在线", "offline": "离线", "unknown": "连接中", + "currentDesktop": "当前电脑", + "presenceUnknown": "状态未知", "switchTo": "切换到 {{name}}", "switchFailed": "无法切换桌面", "emptyDesktops": "暂无配对桌面" @@ -63,45 +216,49 @@ "settings": { "title": "设置", "account": "账号", - "signedInAs": "当前账号", "signOutConfirmTitle": "退出登录?", "signOutConfirmBody": "退出后需要重新登录才能访问远程电脑,不会停止电脑上正在运行的会话", "cancel": "取消", - "manageAccount": "管理 Cloud 账号", - "deleteAccount": "前往 Cloud 删除账号…", + "manageAccount": "管理账号", + "deleteAccount": "前往网页删除账号…", "privacyPolicy": "隐私政策", - "accountWebHint": "打开共用的 Cloud 账号页面,可能需要重新登录;确认删除前,请核对网页邮箱与上方账号一致", "openFailed": "无法打开页面,请重试", "signOut": "退出登录", "connection": "连接", + "authorization": "设备授权", "desktop": "桌面", - "relay": "Relay", "permissionTier": "权限级别", "permissionFull": "完整访问", "permissionReadOnly": "只读", - "mode": "模式", - "modeDemo": "演示", - "modeLive": "在线", - "demoBanner": "演示模式 — 使用本地 fixtures,未连接真实桌面", + "demoBanner": "演示模式 — 当前为示例内容,未连接电脑", "help": "帮助", "pairingGuide": "配对指南", "revokePairing": "解除配对", "online": "在线", "offline": "离线", - "unknownRelay": "未连接", "notAvailable": "—" }, "stopConfirm": { - "failed": "未能停止当前轮次,它可能仍在运行。请重试或检查桌面端。", - "title": "停止当前 Agent turn?", - "body": "会向桌面发送 cancel 指令。已运行的 shell 进程是否终止取决于 Agent 策略", + "title": "停止当前任务?", + "body": "将请求电脑停止当前任务。已经启动的程序可能仍会继续运行,请在电脑上确认", "cancel": "取消", - "confirm": "停止" + "confirm": "停止", + "failed": "未能确认任务已停止,请检查连接后重试" + }, + "permission": { + "submitFailed": "未能确认审批结果,请检查连接后重试" + }, + "connectionRecovery": { + "retry": "重新连接", + "repair": "重新配对", + "retryFailed": "未能重新连接,请重试", + "repairFailed": "未能重置当前连接,请重试" }, "offlineBanner": "桌面离线 — 无法发送消息", "offlineBannerNamed": "{{desktopName}} 离线 — 无法发送消息", - "composerOffline": "桌面离线 — 输入框已禁用", - "composerReadOnly": "这个导入会话暂时只能在手机上查看", + "composerOffline": "电脑已离线,连接后即可发送消息", + "composerReadOnly": "此会话目前只能查看,无法从手机发送消息", + "composerDeviceReadOnly": "这台设备只有查看权限,无法发送消息", "composerSending": "正在发送…", "composerAccepted": "已发送,正在等待 Agent 回复…", "composerUncertain": "连接已变化,正在确认是否送达…", @@ -152,14 +309,8 @@ }, "openDetails": "查看{{tool}}详情", "closeDetails": "关闭工具详情", - "files": "文件", - "openFile": "打开", "fileOpenRequested": "已发送", - "openFileNamed": "在桌面端打开 {{file}}", - "openFileUnavailable": "请使用完整权限连接桌面端后打开文件", "openFileFailed": "无法打开文件({{message}})", - "changes": "更改", - "preview": "预览", "loadingPreview": "正在加载高亮预览", "details": "详情", "output": "输出", @@ -167,14 +318,11 @@ } }, "connectionFailed": "连接失败", - "sessions": { - "limit": "最多显示最近 1,000 条会话", - "loadMore": "加载更多", - "retry": "重试加载" - }, "rounds": { + "oldestFirst": "时间正序 ↑", + "newestFirst": "最新优先 ↓", "incomplete": "未加载更早的轮次", - "truncated": "对话记录已截断", + "truncated": "仅显示部分对话记录", "navigationLabel": "轮次导航" } } diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index ff98fba553..80dfc22bf7 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -586,6 +586,7 @@ "full": "完全访问", "nextTurn": "权限设置从下一轮开始生效" }, + "draftTooLong": "草稿过长(最多 {{max}} 个字符)", "planApprovedLabel": "执行已批准的计划", "planApprovedEditedLabel": "执行已批准的计划(已编辑)", "restoreCheckpoint": "恢复到此检查点", diff --git a/src/icons.ts b/src/icons.ts index 9cbb138451..1c4d78e845 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -411,6 +411,7 @@ export { default as TextBoldIcon } from "@hugeicons/core-free-icons/TextBoldIcon export { default as TextItalicIcon } from "@hugeicons/core-free-icons/TextItalicIcon"; export { default as TextQuoteIcon } from "@hugeicons/core-free-icons/TextQuoteIcon"; export { default as TextStrikethroughIcon } from "@hugeicons/core-free-icons/TextStrikethroughIcon"; +export { default as TextWrapIcon } from "@hugeicons/core-free-icons/TextWrapIcon"; export { default as ThirdBracketIcon } from "@hugeicons/core-free-icons/ThirdBracketIcon"; export { default as Tick01Icon } from "@hugeicons/core-free-icons/Tick01Icon"; export { default as TickDouble01Icon } from "@hugeicons/core-free-icons/TickDouble01Icon"; diff --git a/src/modules/MobileRemote/MobileRemoteApp.drafts.test.ts b/src/modules/MobileRemote/MobileRemoteApp.drafts.test.ts new file mode 100644 index 0000000000..abad9239e3 --- /dev/null +++ b/src/modules/MobileRemote/MobileRemoteApp.drafts.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { MobileRemoteApp } from "./MobileRemoteApp"; +import { MobileComposerDraftContext } from "./components/composer/MobileComposerDraftContext"; +import { createMobileComposerDraftStore } from "./components/composer/mobileComposerDraftStore"; + +const state = vi.hoisted(() => ({ + desktopId: "one", + send: vi.fn(), + optimize: vi.fn(), +})); +vi.mock("./app", () => ({ + MobileRemoteProviders: ({ children }: React.PropsWithChildren) => { + const store = React.useMemo(createMobileComposerDraftStore, []); + React.useEffect(() => () => store.clear(), [store]); + return React.createElement( + MobileComposerDraftContext.Provider, + { value: store }, + children + ); + }, + useMobileRemote: () => ({ + connection: { + status: "connected", + presence: "online", + desktopId: state.desktopId, + tier: "full", + demoMode: false, + }, + connectionConfig: { desktopId: state.desktopId }, + transcriptItems: [], + transcriptPhase: "ready", + transcriptRounds: [], + transcriptRoundsComplete: true, + sessionModel: { options: [], loading: false, patching: false }, + subscribeSession: () => Promise.resolve(), + unsubscribeSession: () => Promise.resolve(), + sendMessage: state.send, + }), +})); +vi.mock("./navigation/useMobileRemoteCoordinator", async () => { + const { reduceMobileRemoteNav, createInitialMobileRemoteNavState } = + await import("./navigation/mobileRemoteNavigation"); + return { + useMobileRemoteCoordinator: () => { + const [nav, dispatch] = React.useReducer( + reduceMobileRemoteNav, + createInitialMobileRemoteNavState({ screen: "sessions" }) + ); + return { + connection: { status: "connected" }, + nav, + dispatch, + showTabBar: !nav.selectedSessionId, + }; + }, + }; +}); +vi.mock("./screens/SessionsScreen", () => ({ + SessionsScreen: ({ + active, + onSelectSession, + }: { + active: boolean; + onSelectSession: (id: string) => void; + }) => + active + ? React.createElement( + "button", + { onClick: () => onSelectSession("a") }, + "Open session" + ) + : null, +})); +vi.mock("./components/profile/MobileProfileEntry", () => ({ + MobileProfileEntry: () => null, +})); +vi.mock("./components/modals/StopConfirmModal", () => ({ + StopConfirmModal: () => null, +})); +vi.mock("./components/transcript/ChatTranscript", () => ({ + ChatTranscript: () => null, +})); +vi.mock("./components/transcript/RoundNavigator", () => ({ + RoundNavigator: () => null, +})); +vi.mock("./components/composer/MobileModelPicker", () => ({ + MobileModelPicker: () => null, +})); +vi.mock("./components/MobileTopBar", () => ({ + MobileTopBar: ({ onBack }: { onBack: () => void }) => + React.createElement("button", { onClick: onBack }, "Back"), +})); +vi.mock("@src/components/PermissionPrompt", () => ({ + PermissionSheet: () => null, +})); +vi.mock("@src/util/optimization/imageOptimizer", () => ({ + optimizeImage: (...args: unknown[]) => state.optimize(...args), +})); +vi.mock("@src/hooks/voice", () => ({ + useVoiceInput: () => ({ + isRecording: false, + isSupported: false, + start: vi.fn(), + stop: vi.fn(), + cancel: vi.fn(), + }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + i18n: { language: "en" }, + }), +})); + +let host: HTMLDivElement; +let root: ReturnType; +const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +let previous: boolean | undefined; +beforeEach(() => { + previous = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + state.desktopId = "one"; + state.send.mockReset().mockResolvedValue(undefined); + state.optimize.mockReset().mockImplementation(async (file: File) => ({ + dataUrl: `data:image/png;base64,${file.name}`, + })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + environment.IS_REACT_ACT_ENVIRONMENT = previous; +}); +const render = (account = "user-a") => + act(async () => + root.render(React.createElement(MobileRemoteApp, { authUserId: account })) + ); +const click = (text: string) => + act(async () => { + const button = Array.from(host.querySelectorAll("button")).find( + (el) => el.textContent === text + ); + if (!button) throw new Error(`Missing ${text}`); + button.click(); + }); +const enter = (text: string) => + act(async () => { + const input = host.querySelector("textarea")!; + Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value" + )!.set!.call(input, text); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +const attach = (name: string) => + act(async () => { + const input = host.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { + configurable: true, + value: [new File(["image"], name, { type: "image/png" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + +it("restores text and photos through the actual chat/list route unmount and keeps desktop/account scopes separate", async () => { + await render(); + await click("Open session"); + await enter("unfinished"); + await attach("one.png"); + await click("Back"); + expect(host.querySelector("textarea")).toBeNull(); + await click("Open session"); + expect(host.querySelector("textarea")?.value).toBe("unfinished"); + expect(host.querySelector('img[src*="one.png"]')).not.toBeNull(); + state.desktopId = "two"; + await render(); + expect(host.querySelector("textarea")?.value).toBe(""); + await enter("other computer"); + state.desktopId = "one"; + await render(); + expect(host.querySelector("textarea")?.value).toBe("unfinished"); + await render("user-b"); + await click("Open session"); + expect(host.querySelector("textarea")?.value).toBe(""); + expect(host.querySelector('img[src*="one.png"]')).toBeNull(); + await render("user-a"); + await click("Open session"); + expect(host.querySelector("textarea")?.value).toBe(""); +}); + +it("keeps failed submissions, preserves newer text/photos after success, and locks duplicate sends across navigation", async () => { + await render(); + await click("Open session"); + await enter("first"); + await attach("one.png"); + state.send.mockRejectedValueOnce(new Error("offline")); + await act(async () => + host + .querySelector('[data-testid="mobile-composer-send"]')! + .click() + ); + expect(host.querySelector("textarea")?.value).toBe("first"); + expect(host.textContent).toContain("offline"); + let finish!: () => void; + state.send.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + await act(async () => + host + .querySelector('[data-testid="mobile-composer-send"]')! + .click() + ); + await click("Back"); + await click("Open session"); + expect( + host.querySelector( + '[data-testid="mobile-composer-send"]' + )?.disabled + ).toBe(true); + await enter("new edit"); + // Editing back to the same contents is still newer intent, not the sent snapshot. + await enter("first"); + await attach("two.png"); + await act(async () => finish()); + expect(host.querySelector("textarea")?.value).toBe("first"); + expect(host.querySelector('img[src*="one.png"]')).toBeNull(); + expect(host.querySelector('img[src*="two.png"]')).not.toBeNull(); + expect(state.send).toHaveBeenCalledTimes(2); +}); + +it("finishes one image preparation while chat is unmounted and restores it on return", async () => { + let finish!: (result: { dataUrl: string }) => void; + state.optimize.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + await render(); + await click("Open session"); + await attach("preparing.png"); + await attach("duplicate.png"); + expect(state.optimize).toHaveBeenCalledTimes(1); + await click("Back"); + await act(async () => + finish({ dataUrl: "data:image/png;base64,preparing.png" }) + ); + await click("Open session"); + expect(host.querySelector('img[src*="preparing.png"]')).not.toBeNull(); + expect( + host.querySelector( + '[data-testid="mobile-composer-send"]' + )?.disabled + ).toBe(false); +}); + +it("does not apply a late image preparation result to another account", async () => { + let finish!: (result: { dataUrl: string }) => void; + state.optimize.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + await render(); + await click("Open session"); + await attach("late.png"); + await render("user-b"); + await click("Open session"); + await enter("private new draft"); + await act(async () => finish({ dataUrl: "data:image/png;base64,late.png" })); + expect(host.querySelector("textarea")?.value).toBe("private new draft"); + expect(host.querySelector('img[src*="late.png"]')).toBeNull(); +}); diff --git a/src/modules/MobileRemote/MobileRemoteApp.identity.test.ts b/src/modules/MobileRemote/MobileRemoteApp.identity.test.ts new file mode 100644 index 0000000000..4190f2af98 --- /dev/null +++ b/src/modules/MobileRemote/MobileRemoteApp.identity.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +import { MobileRemoteApp } from "./MobileRemoteApp"; +import { + MobileAuthContext, + type MobileAuthContextValue, +} from "./auth/MobileAuthContext"; + +const lifecycle = vi.hoisted(() => ({ mount: vi.fn(), dispose: vi.fn() })); +vi.mock("./app", async () => { + const React = await import("react"); + return { + MobileRemoteProviders: () => { + React.useEffect(() => { + lifecycle.mount(); + return lifecycle.dispose; + }, []); + return null; + }, + useMobileRemote: vi.fn(), + }; +}); + +it("disposes connection, inbox and search owners on account/endpoint changes, not token refresh", async () => { + const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + const before = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + const root = createRoot(document.createElement("div")); + let value: MobileAuthContextValue = { + isDevelopmentBypass: false, + signOut: vi.fn(), + session: { + kind: "org2_cloud", + userId: "one", + supabaseUrl: "https://one.example", + supabaseAnonKey: "public", + accessToken: "first", + refreshToken: "refresh", + expiresAt: 1000, + }, + }; + const render = () => + act(async () => + root.render( + React.createElement( + MobileAuthContext.Provider, + { value }, + React.createElement(MobileRemoteApp, { + authUserId: value.session.userId, + }) + ) + ) + ); + try { + await render(); + value = { + ...value, + session: { ...value.session, accessToken: "refreshed" }, + }; + await render(); + expect(lifecycle.mount).toHaveBeenCalledTimes(1); + value = { ...value, session: { ...value.session, userId: "two" } }; + await render(); + expect(lifecycle.dispose).toHaveBeenCalledTimes(1); + value = { + ...value, + session: { ...value.session, supabaseUrl: "https://two.example" }, + }; + await render(); + expect(lifecycle.mount).toHaveBeenCalledTimes(3); + expect(lifecycle.dispose).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + environment.IS_REACT_ACT_ENVIRONMENT = before; + } +}); diff --git a/src/modules/MobileRemote/MobileRemoteApp.reconnect.test.ts b/src/modules/MobileRemote/MobileRemoteApp.reconnect.test.ts new file mode 100644 index 0000000000..9293d69417 --- /dev/null +++ b/src/modules/MobileRemote/MobileRemoteApp.reconnect.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import i18n, { i18nReady } from "@src/i18n"; +import enMobileRemote from "@src/i18n/locales/en/mobileRemote.json"; + +import { MobileRemoteApp } from "./MobileRemoteApp"; +import { MobileAuthContext } from "./auth/MobileAuthContext"; +import { MobileRemotePlatformProvider } from "./platform"; +import { createBrowserMobileRemotePlatform } from "./platform/browser"; + +// Only the transport is simulated: the root render gate, navigation, provider, +// RPC framing, storage and session/offline UI run their production code. +class RelaySocket extends EventTarget { + readyState = 0; + static online = false; + static instances: RelaySocket[] = []; + + constructor(readonly url: string) { + super(); + RelaySocket.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + // Relay recognizes this fixture's durable credential even if an older + // app retained its expired one-time code; Desktop need not be online. + queueMicrotask(() => + this.receive({ jsonrpc: "2.0", method: "pairing/approved", params: {} }) + ); + }); + } + receive(message: unknown) { + this.dispatchEvent( + new MessageEvent("message", { data: JSON.stringify(message) }) + ); + } + send(raw: string) { + const request = JSON.parse(raw); + if (request.id == null) return; + queueMicrotask(() => + this.receive({ + jsonrpc: "2.0", + id: request.id, + ...(!RelaySocket.online + ? { error: { code: -32006, message: "desktop is offline" } } + : { + result: + request.method === "initialize" + ? { + protocolVersion: 1, + tier: "full", + desktopId: "fixture-desktop", + } + : { sessions: [] }, + }), + }) + ); + } + close(code = 1000) { + if (this.readyState === 3) return; + this.readyState = 3; + this.dispatchEvent(new CloseEvent("close", { code })); + } +} + +const PlatformProvider = MobileRemotePlatformProvider as React.ComponentType< + React.PropsWithChildren< + Omit, "children"> + > +>; + +describe("MobileRemoteApp paired-device recovery", () => { + let root: Root; + let container: HTMLDivElement; + const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + let previousActEnvironment: boolean | undefined; + let platform: ReturnType; + beforeEach(async () => { + previousActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + RelaySocket.online = false; + RelaySocket.instances = []; + platform = createBrowserMobileRemotePlatform(); + platform.connection.createSocket = (url) => + new RelaySocket(url) as unknown as WebSocket; + platform.runtime.random = () => 0; + await i18nReady; + i18n.addResourceBundle("en", "mobileRemote", enMobileRemote, true, true); + await i18n.changeLanguage("en"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + environment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + }); + async function render() { + await act(async () => + root.render( + React.createElement( + I18nextProvider, + { i18n }, + React.createElement( + PlatformProvider, + { platform }, + React.createElement( + MobileAuthContext.Provider, + { + value: { + isDevelopmentBypass: false, + signOut: vi.fn(), + session: { + kind: "org2_cloud", + userId: "reconnect-fixture", + supabaseUrl: "https://auth.example.test", + supabaseAnonKey: "fixture-public", + accessToken: "fixture-access", + refreshToken: "fixture-refresh", + expiresAt: 9999999999, + }, + }, + }, + React.createElement(MobileRemoteApp, { + authUserId: "reconnect-fixture", + }) + ) + ) + ) + ) + ); + } + + it("keeps the session page across offline launch and transport loss, but honors revocation", async () => { + const config = { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + desktopId: "fixture-desktop", + deviceToken: "fixture-credential", + }; + await platform.connection.save("reconnect-fixture", config); + await render(); + expect(container.querySelector(".mobile-discovery")).not.toBeNull(); + expect(container.textContent).not.toContain(enMobileRemote.welcome.scanQr); + expect(RelaySocket.instances).toHaveLength(1); + RelaySocket.online = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(RelaySocket.instances).toHaveLength(2); + expect(container.querySelector(".mobile-discovery")).not.toBeNull(); + await act(async () => RelaySocket.instances.at(-1)!.close(1006)); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(RelaySocket.instances).toHaveLength(3); + expect(await platform.connection.load("reconnect-fixture")).toEqual(config); + expect(container.querySelector(".mobile-discovery")).not.toBeNull(); + await act(async () => RelaySocket.instances.at(-1)!.close(1008)); + expect(container.querySelector(".mobile-discovery")).toBeNull(); + expect(container.textContent).toContain("Device access was revoked"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(RelaySocket.instances).toHaveLength(3); + }); + + it("repairs an old paired record only after Relay approval and restores sessions while Desktop is offline", async () => { + await platform.connection.save("reconnect-fixture", { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + desktopId: "fixture-desktop", + deviceToken: "fixture-credential", + pairingCode: "old-one-time-code", + }); + await render(); + expect(container.querySelector(".mobile-discovery")).not.toBeNull(); + expect( + (await platform.connection.load("reconnect-fixture"))?.pairingCode + ).toBeUndefined(); + RelaySocket.online = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(RelaySocket.instances).toHaveLength(2); + expect( + new URL(RelaySocket.instances[1].url).searchParams.has("pairingCode") + ).toBe(false); + expect(container.querySelector(".mobile-discovery")).not.toBeNull(); + }); +}); diff --git a/src/modules/MobileRemote/MobileRemoteApp.recovery.test.ts b/src/modules/MobileRemote/MobileRemoteApp.recovery.test.ts new file mode 100644 index 0000000000..89a0855b39 --- /dev/null +++ b/src/modules/MobileRemote/MobileRemoteApp.recovery.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { MobileRemoteApp } from "./MobileRemoteApp"; + +const mocks = vi.hoisted(() => ({ context: {} as Record })); +vi.mock("./app", () => ({ + MobileRemoteProviders: ({ children }: React.PropsWithChildren) => children, + useMobileRemote: () => mocks.context, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("./components/MobileShell", () => ({ + MobileShell: ({ children }: React.PropsWithChildren) => + React.createElement("main", null, children), +})); +vi.mock("./components/MobileTabBar", () => ({ MobileTabBar: () => null })); +vi.mock("./components/profile/MobileProfileEntry", () => ({ + MobileProfileEntry: () => null, +})); +vi.mock("./screens/WelcomeScreen", () => ({ + WelcomeScreen: () => React.createElement("div", null, "pairing-home"), +})); +vi.mock("./screens/SessionChatScreen", () => ({ + SessionChatScreen: () => null, +})); +vi.mock("./screens/SessionsScreen", () => ({ SessionsScreen: () => null })); +vi.mock("./screens/devices/DevicesTab", () => ({ DevicesTab: () => null })); +vi.mock("./screens/settings/SettingsTab", () => ({ SettingsTab: () => null })); +vi.mock("./components/MobileActionButton", () => ({ + MobileActionButton: ({ + children, + variant: _variant, + ...props + }: React.ButtonHTMLAttributes & { variant?: string }) => + React.createElement("button", props, children), +})); +vi.mock("@src/components/Placeholder", () => ({ + Placeholder: ({ title }: { title: string }) => + React.createElement("p", null, title), +})); + +let container: HTMLDivElement; +let root: ReturnType; +const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +let previousAct: boolean | undefined; +const render = () => + act(async () => + root.render( + React.createElement(MobileRemoteApp, { authUserId: "test-user" }) + ) + ); +const button = (label: string) => + Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent === label + )!; + +beforeEach(() => { + previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + mocks.context = { + connection: { + status: "error", + presence: "offline", + demoMode: false, + error: { message: "Unavailable" }, + }, + connectionConfig: { + desktopId: "desktop-a", + wsUrl: "wss://relay.example/v1/mobile/ws", + }, + sessions: [], + bootstrapPending: false, + retryConnection: vi.fn().mockRejectedValue(new Error("network failure")), + disconnect: vi.fn().mockResolvedValue(undefined), + stopSession: vi.fn(), + }; +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + environment.IS_REACT_ACT_ENVIRONMENT = previousAct; +}); + +it("the error-page Reconnect action retries the selected connection and exposes failure without unpairing", async () => { + await render(); + await act(async () => button("connectionRecovery.retry").click()); + expect(mocks.context.retryConnection).toHaveBeenCalledTimes(1); + expect(mocks.context.disconnect).not.toHaveBeenCalled(); + expect(container.textContent).toContain("connectionRecovery.retryFailed"); + expect(button("connectionRecovery.retry").disabled).toBe(false); + expect(container.textContent).not.toContain("pairing-home"); +}); + +it("Pair again is separate and enters welcome only after successful reset", async () => { + mocks.context.disconnect = vi.fn().mockImplementation(async () => { + // The real provider clears runtime selection synchronously before its + // persistence promise settles; mirror that ordering at this boundary. + mocks.context = { + ...mocks.context, + connectionConfig: null, + connection: { + status: "disconnected", + presence: "unknown", + demoMode: false, + }, + }; + }); + await render(); + await act(async () => button("connectionRecovery.repair").click()); + expect(mocks.context.disconnect).toHaveBeenCalledTimes(1); + expect(mocks.context.retryConnection).not.toHaveBeenCalled(); + mocks.context = { + ...mocks.context, + connectionConfig: null, + connection: { + status: "disconnected", + presence: "unknown", + demoMode: false, + }, + }; + await render(); + expect(container.textContent).toContain("pairing-home"); +}); + +it("a failed reset remains on the error page with a recoverable message", async () => { + mocks.context.disconnect = vi.fn().mockRejectedValue(new Error("storage")); + await render(); + await act(async () => button("connectionRecovery.repair").click()); + expect(container.textContent).toContain("connectionRecovery.repairFailed"); + expect(container.textContent).not.toContain("pairing-home"); +}); + +it("keeps recovery on a loading surface until the single handshake settles", async () => { + let reject!: (error: Error) => void; + mocks.context.retryConnection = vi.fn().mockImplementation( + () => + new Promise((_resolve, fail) => { + reject = fail; + }) + ); + await render(); + await act(async () => button("connectionRecovery.retry").click()); + expect(container.textContent).toContain("connection.restoring"); + expect(container.textContent).not.toContain("pairing-home"); + expect(container.querySelectorAll("button")).toHaveLength(0); + await act(async () => reject(new Error("timeout"))); + expect(container.textContent).toContain("connectionRecovery.retryFailed"); + expect(mocks.context.retryConnection).toHaveBeenCalledTimes(1); +}); diff --git a/src/modules/MobileRemote/MobileRemoteApp.search.test.ts b/src/modules/MobileRemote/MobileRemoteApp.search.test.ts new file mode 100644 index 0000000000..1aca41ea70 --- /dev/null +++ b/src/modules/MobileRemote/MobileRemoteApp.search.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { MobileRemoteApp } from "./MobileRemoteApp"; +import type { MobileRpcClient } from "./connection/mobileRpcClient"; + +const state = vi.hoisted(() => ({ + connection: { + status: "connected", + presence: "online", + desktopId: "one", + capabilities: { sessionSearch: true }, + }, + connectionConfig: { desktopId: "one", host: "localhost", port: 8787 }, + sessions: [{ id: "recent", name: "Recent", status: "idle" }], + rpc: null as MobileRpcClient | null, +})); +vi.mock("./app", () => ({ + MobileRemoteProviders: ({ children }: React.PropsWithChildren) => children, + useMobileRemote: () => state, +})); +vi.mock("./platform", async (importOriginal) => { + const actual = await importOriginal(); + const { createBrowserMobileRemotePlatform } = + await import("./platform/browser"); + const platform = createBrowserMobileRemotePlatform(); + return { ...actual, useMobileRemotePlatform: () => platform }; +}); +vi.mock("./navigation/useMobileRemoteCoordinator", async () => { + const React = await import("react"); + const { reduceMobileRemoteNav, createInitialMobileRemoteNavState } = + await import("./navigation/mobileRemoteNavigation"); + return { + useMobileRemoteCoordinator: () => { + const [nav, dispatch] = React.useReducer( + reduceMobileRemoteNav, + createInitialMobileRemoteNavState({ screen: "sessions" }) + ); + return { + connection: state.connection, + nav, + dispatch, + showTabBar: !nav.selectedSessionId, + }; + }, + }; +}); +vi.mock("./components/profile/MobileProfileEntry", () => ({ + MobileProfileEntry: () => null, +})); +vi.mock("./components/modals/StopConfirmModal", () => ({ + StopConfirmModal: () => null, +})); +vi.mock("./screens/SessionChatScreen", () => ({ + SessionChatScreen: ({ onBack }: { onBack: () => void }) => + React.createElement("button", { onClick: onBack }, "Back from chat"), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); + +let host: HTMLDivElement; +let root: ReturnType; +let call: ReturnType; +let previousActEnvironment: boolean | undefined; +const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; +beforeEach(() => { + previousActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + call = vi.fn().mockResolvedValue({ + sessions: [{ id: "historic", name: "Historic result", status: "idle" }], + nextOffset: 50, + hasMore: false, + }); + state.rpc = { call } as unknown as MobileRpcClient; + state.connectionConfig.desktopId = "one"; +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); + environment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; +}); +const render = () => + act(async () => + root.render( + React.createElement(MobileRemoteApp, { authUserId: "test-account" }) + ) + ); +const search = async () => { + await act(async () => + host + .querySelector('[aria-label="search.title"]')! + .click() + ); + await act(async () => { + const input = host.querySelector("input")!; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )!.set!.call(input, "history"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => vi.advanceTimersByTime(200)); +}; + +it("preserves inline search through the actual App route switch and back action", async () => { + await render(); + await search(); + const results = host.querySelector(".mobile-discovery-scroll")!; + results.scrollTop = 180; + const row = Array.from( + host.querySelectorAll( + '[data-testid="mobile-remote-session-row"]' + ) + ).find((item) => item.textContent?.includes("Historic result"))!; + await act(async () => row.click()); + expect(host.textContent).toBe("Back from chat"); + expect(host.querySelector("input")).toBeNull(); + await act(async () => vi.advanceTimersByTime(1000)); + expect(call).toHaveBeenCalledTimes(1); + await act(async () => host.querySelector("button")!.click()); + expect(host.querySelector("input")?.value).toBe("history"); + expect(host.textContent).toContain("Historic result"); + expect(host.querySelector(".mobile-discovery-scroll")?.scrollTop).toBe(180); + expect(call).toHaveBeenCalledTimes(1); +}); + +it("clears the retained search owner when the paired desktop changes", async () => { + await render(); + await search(); + state.connectionConfig.desktopId = "two"; + await render(); + expect(host.querySelector("input")).toBeNull(); + expect(host.textContent).not.toContain("Historic result"); + await act(async () => vi.advanceTimersByTime(1000)); + expect(call).toHaveBeenCalledTimes(1); +}); diff --git a/src/modules/MobileRemote/MobileRemoteApp.test.ts b/src/modules/MobileRemote/MobileRemoteApp.test.ts index ec91b4eb48..eada99c4fb 100644 --- a/src/modules/MobileRemote/MobileRemoteApp.test.ts +++ b/src/modules/MobileRemote/MobileRemoteApp.test.ts @@ -21,7 +21,7 @@ const TestMobileRemotePlatformProvider = >; describe("MobileRemoteApp", () => { - it("renders welcome markup within providers", async () => { + it("does not flash pairing before persisted device hydration", async () => { await i18nReady; i18n.addResourceBundle("en", "mobileRemote", enMobileRemote, true, true); await i18n.changeLanguage("en"); @@ -36,8 +36,8 @@ describe("MobileRemoteApp", () => { ) ) ); - expect(markup).toContain("Mobile Remote"); expect(markup).not.toContain("Try demo"); - expect(markup).toContain(enMobileRemote.welcome.scanQr); + expect(markup).not.toContain(enMobileRemote.welcome.scanQr); + expect(markup).toContain('aria-busy="true"'); }); }); diff --git a/src/modules/MobileRemote/MobileRemoteApp.tsx b/src/modules/MobileRemote/MobileRemoteApp.tsx index 096994ca7d..c8e4393102 100644 --- a/src/modules/MobileRemote/MobileRemoteApp.tsx +++ b/src/modules/MobileRemote/MobileRemoteApp.tsx @@ -1,11 +1,15 @@ -import React from "react"; +import React, { useContext } from "react"; -import { MobileRemoteProviders } from "./app"; +import { createLogger } from "@src/hooks/logger"; + +import { MobileRemoteProviders, useMobileRemote } from "./app"; +import { MobileAuthContext } from "./auth/MobileAuthContext"; import { MobileShell } from "./components/MobileShell"; import { MobileTabBar } from "./components/MobileTabBar"; -import { StopConfirmModal } from "./components/modals/StopConfirmModal"; +import { MobileProfileEntry } from "./components/profile/MobileProfileEntry"; import { useMobileRemoteCoordinator } from "./navigation/useMobileRemoteCoordinator"; import { ConnectingLiveBridge } from "./screens/ConnectingLiveBridge"; +import { ConnectingScreen } from "./screens/ConnectingScreen"; import { ConnectionErrorScreen } from "./screens/ConnectionErrorScreen"; import { QRScanScreen } from "./screens/QRScanScreen"; import { SASConfirmScreen } from "./screens/SASConfirmScreen"; @@ -34,23 +38,45 @@ function MobileRemoteRoutes({ connection, nav, dispatch, - stopConfirming, - stopFailed, + connectionRecovering, + connectionRecoveryError, showTabBar, selectedSessionName, selectedSessionSendCapability, handleConnectingComplete, handleAcceptPairing, - handleConfirmStop, handleConnectionRetry, + handleConnectionRepair, } = useMobileRemoteCoordinator(recoveredPairingIntent); + const { bootstrapPending, connectionConfig } = useMobileRemote(); + + // Manual recovery owns the handshake; do not remount ConnectingLiveBridge + // with an old pending config and start a second connection in parallel. + if (bootstrapPending || connectionRecovering) { + return ( + + + + ); + } if (connection.status === "error") { return ( { + void handleConnectionRetry().catch((error) => + logger.warn("Connection retry failed", error) + ); + }} + onRepair={() => { + void handleConnectionRepair().catch((error) => + logger.warn("Connection repair failed", error) + ); + }} + busy={connectionRecovering} + actionError={connectionRecoveryError} /> ); @@ -61,6 +87,7 @@ function MobileRemoteRoutes({ case "welcome": body = ( } onOpenPairing={() => dispatch({ type: "open_qr_scan" })} /> ); @@ -97,18 +124,13 @@ function MobileRemoteRoutes({ body = ( <> + dispatch({ type: "select_session", sessionId }) + } sessionId={nav.selectedSessionId} sessionName={selectedSessionName} sendCapability={selectedSessionSendCapability} onBack={() => dispatch({ type: "back_from_chat" })} - onOpenStopModal={() => dispatch({ type: "open_stop_modal" })} - /> - dispatch({ type: "close_stop_modal" })} - onConfirm={() => void handleConfirmStop().catch(() => undefined)} /> ); @@ -117,13 +139,7 @@ function MobileRemoteRoutes({ } else if (nav.activeTab === "settings") { body = ; } else { - body = ( - - dispatch({ type: "select_session", sessionId }) - } - /> - ); + body = null; } break; default: @@ -141,6 +157,20 @@ function MobileRemoteRoutes({ ) : null } > + {nav.screen === "sessions" || nav.screen === "chat" ? ( + } + onSelectSession={(sessionId) => + dispatch({ type: "select_session", sessionId }) + } + /> + ) : null} {body} ); @@ -152,8 +182,10 @@ export function MobileRemoteApp({ recoveredPairingIntent = null, relayUrl, }: MobileRemoteAppProps) { + const auth = useContext(MobileAuthContext); return ( .truncate { + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + } + } +} + +@mixin search-field { + --mobile-search-height: 48px; + --mobile-search-radius: 999px; + --mobile-search-padding-x: #{$space-4}; + --mobile-search-icon-size: 20px; + --mobile-search-font-size: var(--mobile-type-body-size); + --mobile-search-gap: #{$space-2}; + --mobile-search-surface: var(--color-bg-2); + --mobile-search-border: var(--color-border-1); + --mobile-search-shadow: var(--shadow-dropdown-soft); +} + +// Apply on the surface, not only .mobile-shell: menus portal to document.body. +@mixin compact-menu { + --mobile-menu-width: 224px; + --mobile-menu-viewport-inset: #{$space-4}; + --mobile-menu-radius: #{$space-4}; + --mobile-menu-padding: #{$space-2}; + --mobile-menu-row-height: #{$touch-target}; + --mobile-menu-row-padding-y: #{$space-2}; + --mobile-menu-row-padding-x: #{$space-3}; + --mobile-menu-row-gap: #{$space-2}; + --mobile-menu-row-radius: #{$space-2}; + --mobile-menu-font-size: var(--mobile-type-control-size); + --mobile-menu-line-height: var(--mobile-type-control-leading); + --mobile-menu-caption-size: var(--mobile-type-caption-size); + --mobile-menu-caption-line-height: var(--mobile-type-caption-leading); + --mobile-menu-caption-padding-y: #{$space-1}; + --mobile-menu-icon-size: 18px; +} diff --git a/src/modules/MobileRemote/app/MobileRemoteContext.ts b/src/modules/MobileRemote/app/MobileRemoteContext.ts index f18314b98b..0dbb3829ee 100644 --- a/src/modules/MobileRemote/app/MobileRemoteContext.ts +++ b/src/modules/MobileRemote/app/MobileRemoteContext.ts @@ -1,8 +1,8 @@ -import { createContext, useContext } from "react"; +import React, { createContext, useContext } from "react"; import type { PermissionSheetRequest } from "@src/components/PermissionPrompt"; -import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { type MobileRpcClient } from "../connection/mobileRpcClient"; import type { MobileConnectionConfig, MobileConnectionState, @@ -17,13 +17,26 @@ import type { TranscriptRoundSummary, } from "../lib/transcriptLoadState"; import type { TranscriptItem } from "../lib/transcriptReducer"; -import type { MobileSendStatus } from "./useMobileSend"; +import type { MobileReadStateSync } from "./mobileReadStateSync"; +import { type MobilePendingInbox } from "./useMobilePendingInbox"; +import { type MobileSendStatus } from "./useMobileSend"; export interface MobileRemoteContextValue { + readStateSync: MobileReadStateSync; + pendingInbox: MobilePendingInbox; + focusPermission: (requestId: string) => void; + bootstrapPending: boolean; connection: MobileConnectionState; sessions: MobileSessionRow[]; transcriptItems: TranscriptItem[]; transcriptPhase: TranscriptLoadPhase; + transcriptSessionId: string | null; + openingReady: boolean; + openedSession: { + requested: string; + sessionId: string; + managed: boolean; + } | null; transcriptError?: string; transcriptTruncated: boolean; transcriptRounds: TranscriptRoundSummary[]; @@ -36,10 +49,13 @@ export interface MobileRemoteContextValue { permissionQueueDepth: number; /** True while an answer is on the wire; the sheet must stay disabled. */ permissionSubmitting: boolean; + /** Failure belongs to the currently presented permission request only. */ + permissionFailed: boolean; rpc: MobileRpcClient | null; connectionConfig: MobileConnectionConfig | null; pairedDesktops: MobilePairedDesktopSummary[]; connectLive: (config: MobileConnectionConfig) => Promise; + retryConnection: () => Promise; switchPairedDesktop: (desktopId: string) => Promise; enterDemoMode: () => void; disconnect: () => Promise; @@ -68,6 +84,7 @@ export interface MobileRemoteContextValue { stopSession: (sessionId: string) => Promise; sessionModel: MobileSessionModelState; refreshSessionModel: (sessionId: string) => Promise; + loadSessionModels: (sessionId: string) => Promise; setSessionModel: ( sessionId: string, option: MobileModelOption @@ -77,6 +94,16 @@ export interface MobileRemoteContextValue { export const MobileRemoteContext = createContext(null); +export interface MobileRemoteProvidersProps { + children: React.ReactNode; + /** Authenticated ORG2 Cloud subject; scopes all retained pairing state. */ + authUserId: string; + relayUrl?: string; + demoByDefault?: boolean; + /** A freshly scanned QR must take precedence over a stored old desktop. */ + suppressInitialBootstrap?: boolean; +} + export function useMobileRemote(): MobileRemoteContextValue { const value = useContext(MobileRemoteContext); if (!value) { diff --git a/src/modules/MobileRemote/app/MobileRemoteProviders.test.ts b/src/modules/MobileRemote/app/MobileRemoteProviders.test.ts index a3670a2bbe..d885dd1520 100644 --- a/src/modules/MobileRemote/app/MobileRemoteProviders.test.ts +++ b/src/modules/MobileRemote/app/MobileRemoteProviders.test.ts @@ -12,6 +12,12 @@ import { vi, } from "vitest"; +import { MobileComposerDraftContext } from "../components/composer/MobileComposerDraftContext"; +import { mobileComposerDesktopScope } from "../components/composer/mobileComposerDraftStore"; +import type { MobileComposerDraftStore } from "../components/composer/mobileComposerDraftStore"; +import { loadScopedMobileConnectionConfig } from "../connection/mobileConnectionStorage"; +import type { MobileConnectionConfig } from "../connection/types"; +import { MobileConnectionAuthorizationError } from "../connection/types"; import { MobileRemotePlatformProvider } from "../platform"; import { createBrowserMobileRemotePlatform } from "../platform/browser"; import { @@ -49,25 +55,35 @@ vi.mock("../connection/mobileRpcClient", async (importOriginal) => { await importOriginal(); return { ...original, - createMobileRpcClient: (socket: WebSocket) => ({ - call: mocks.call, - notify: vi.fn(), - onNotification: ( - handler: (method: string, params?: Record) => void - ) => { - mocks.notificationHandler = handler; - return () => { - if (mocks.notificationHandler === handler) { + createMobileRpcClient: (socket: WebSocket) => { + const subscribers = new Set< + (method: string, params?: Record) => void + >(); + const notify = (method: string, params?: Record) => { + for (const handler of subscribers) handler(method, params); + }; + mocks.notificationHandler = notify; + return { + call: mocks.call, + notify: vi.fn(), + onNotification: ( + handler: (method: string, params?: Record) => void + ) => { + subscribers.add(handler); + return () => { + subscribers.delete(handler); + }; + }, + close: () => { + mocks.close(); + subscribers.clear(); + if (mocks.notificationHandler === notify) mocks.notificationHandler = null; - } - }; - }, - close: () => { - mocks.close(); - socket.close(); - }, - readyState: 1, - }), + socket.close(); + }, + readyState: 1, + }; + }, }; }); @@ -114,117 +130,1386 @@ function deferred() { resolve = resolvePromise; reject = rejectPromise; }); - return { promise, resolve, reject }; -} - -describe("MobileRemoteProviders send lifecycle", () => { - let container: HTMLDivElement; - let root: Root; - let latestContext: MobileRemoteContextValue | null; - let sendResult: ReturnType>; - const actEnvironment = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - }; - - function Probe(): null { - const context = useMobileRemote(); - React.useEffect(() => { - latestContext = context; - }, [context]); - return null; - } + return { promise, resolve, reject }; +} + +describe("MobileRemoteProviders send lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + let latestContext: MobileRemoteContextValue | null; + let latestDraftStore: MobileComposerDraftStore | null; + let sendResult: ReturnType>; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + function Probe(): null { + const context = useMobileRemote(); + const drafts = React.useContext(MobileComposerDraftContext); + React.useEffect(() => { + latestContext = context; + latestDraftStore = drafts; + }, [context, drafts]); + return null; + } + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal("WebSocket", FakeWebSocket); + }); + + beforeEach(async () => { + latestContext = null; + latestDraftStore = null; + sendResult = deferred<{ execution: string }>(); + mocks.sendParams = null; + mocks.externalRoundId = null; + mocks.notificationHandler = null; + mocks.close.mockReset(); + FakeWebSocket.instances = []; + FakeWebSocket.deferCloseEvents = false; + FakeWebSocket.pendingCloseEvents = []; + mocks.call + .mockReset() + .mockImplementation( + (method: string, params?: Record) => { + if (method === "initialize") { + return Promise.resolve({ protocolVersion: 1, tier: "full" }); + } + if (method === "session/list") { + return Promise.resolve({ sessions: [] }); + } + if (method === "session/subscribe") { + const externalRound = mocks.externalRoundId + ? [ + { + id: mocks.externalRoundId, + userPreview: "external turn", + }, + ] + : []; + return Promise.resolve({ + sessionId: params?.sessionId, + rounds: { + items: [ + { id: "round-1", userPreview: "First" }, + { id: "round-2", userPreview: "Second" }, + { id: "round-3", userPreview: "Latest" }, + ...externalRound, + ], + complete: true, + }, + snapshot: { + sessionId: params?.sessionId, + roundId: mocks.externalRoundId ?? "round-3", + version: 1, + snapshotDelta: false, + upserts: [], + }, + }); + } + if (method === "session/round") { + const roundId = String(params?.roundId); + return Promise.resolve({ + sessionId: params?.sessionId, + roundId, + snapshot: { + sessionId: params?.sessionId, + roundId, + version: 1, + snapshotDelta: false, + upserts: [ + { + id: `agent-${roundId}`, + source: "assistant", + displayVariant: "message", + displayText: `History ${roundId}`, + }, + ], + }, + }); + } + if (method === "session/send") { + mocks.sendParams = params ?? null; + return sendResult.promise; + } + return Promise.resolve({}); + } + ); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render( + React.createElement( + TestMobileRemotePlatformProvider, + { platform: createBrowserMobileRemotePlatform() }, + React.createElement( + TestMobileRemoteProviders, + { + authUserId: "user-a", + demoByDefault: false, + suppressInitialBootstrap: true, + }, + React.createElement(Probe) + ) + ) + ); + }); + await act(async () => { + await latestContext?.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + }); + }); + await act(async () => { + await latestContext?.subscribeSession("session-a"); + }); + }); + + it("ignores a late opening and closes only its lease after switching chats", async () => { + const old = deferred(); + const base = mocks.call.getMockImplementation()!; + const reply = (params?: Record) => ({ + sessionId: params?.sessionId, + managed: false, + subscriptionId: params?.subscriptionId, + rounds: { items: [], complete: true }, + snapshot: { + sessionId: params?.sessionId, + upserts: [], + snapshotDelta: false, + version: 1, + }, + }); + let oldParams: Record | undefined; + mocks.call.mockImplementation( + (method: string, params?: Record) => { + if (method === "initialize") + return Promise.resolve({ + protocolVersion: 1, + tier: "full", + capabilities: { sessionOpen: true }, + }); + if (method === "session/open") { + if (params?.sessionId === "old") { + oldParams = params; + return old.promise; + } + return Promise.resolve(reply(params)); + } + return base(method, params); + } + ); + await act(async () => { + await latestContext!.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + }); + }); + mocks.call.mockClear(); + let first!: Promise; + await act(async () => { + first = latestContext!.subscribeSession("old"); + }); + await act(async () => { + for (let i = 0; i < 10; i++) + mocks.notificationHandler!("session/list_changed", {}); + }); + expect( + mocks.call.mock.calls.filter(([method]) => method === "session/open") + ).toHaveLength(1); + await act(async () => { + await latestContext!.subscribeSession("new"); + }); + await act(async () => { + old.resolve(reply(oldParams)); + await first; + }); + expect(latestContext!.openedSession?.sessionId).toBe("new"); + expect(latestContext!.transcriptSessionId).toBe("new"); + expect(mocks.call).toHaveBeenCalledWith("session/unsubscribe", { + subscriptionId: oldParams!.subscriptionId, + }); + // Replacing this connection with a legacy Desktop must not let its old + // lease suppress the new connection's session-id unsubscribe. + mocks.call.mockImplementation( + (method: string, params?: Record) => { + if (method === "initialize") + return Promise.resolve({ protocolVersion: 1, tier: "full" }); + return base(method, params); + } + ); + await act(async () => { + await latestContext!.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + }); + }); + await act(async () => { + await latestContext!.unsubscribeSession(); + }); + expect(mocks.call).toHaveBeenCalledWith("session/unsubscribe", { + sessionId: "new", + }); + }); + + it("combined opening owns canonical history, independent models, and a lease close", async () => { + const permission = deferred(); + const base = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation( + (method: string, params?: Record) => { + if (method === "initialize") + return Promise.resolve({ + protocolVersion: 1, + tier: "full", + capabilities: { sessionOpen: true, pendingInteractions: true }, + }); + if (method === "session/open") + return Promise.resolve({ + sessionId: + params?.sessionId === "mirror" ? "owner" : params?.sessionId, + managed: params?.sessionId === "mirror", + subscriptionId: params?.subscriptionId, + rounds: { items: [], complete: true }, + snapshot: { + sessionId: + params?.sessionId === "mirror" ? "owner" : params?.sessionId, + upserts: [], + snapshotDelta: false, + version: 1, + }, + }); + if (method === "interaction/pending" && params?.sessionId === "owner") + return permission.promise; + if (method === "session/config") + return Promise.resolve({ + sessionId: params?.sessionId, + model: "current", + modelEditable: true, + }); + return base(method, params); + } + ); + await act(async () => { + await latestContext!.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + }); + }); + mocks.call.mockClear(); + let opening!: Promise; + await act(async () => { + opening = latestContext!.subscribeSession("mirror"); + }); + expect(latestContext!.openedSession).toEqual({ + requested: "mirror", + sessionId: "owner", + managed: true, + }); + expect(latestContext!.transcriptSessionId).toBe("owner"); + expect(latestContext!.sessionModel.config?.model).toBe("current"); + expect(mocks.call).toHaveBeenCalledWith("session/config", { + sessionId: "owner", + }); + expect( + mocks.call.mock.calls.some( + ([method]) => method === "models/list" || method === "session/resolve" + ) + ).toBe(false); + const lease = mocks.call.mock.calls.find( + ([method]) => method === "session/open" + )![1].subscriptionId; + await act(async () => { + permission.reject(new Error("permission timeout")); + await opening; + }); + await act(async () => { + await latestContext!.unsubscribeSession(); + }); + expect(mocks.call).toHaveBeenCalledWith("session/unsubscribe", { + subscriptionId: lease, + }); + }); + + it("restores the active transcript before a slow reconnect roster finishes", async () => { + const roster = deferred(); + const base = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation( + (method: string, params?: Record) => { + if (method === "session/list") return roster.promise; + if (method === "session/subscribe") { + return Promise.resolve({ + sessionId: params?.sessionId, + rounds: { items: [{ id: "recovered-round" }], complete: true }, + snapshot: { + sessionId: params?.sessionId, + roundId: "recovered-round", + version: 2, + snapshotDelta: false, + upserts: [ + { + id: "recovered-reply", + source: "assistant", + displayVariant: "message", + displayText: "Recovered before roster", + }, + ], + }, + }); + } + return base(method, params); + } + ); + let recovery!: Promise; + await act(async () => { + recovery = latestContext!.retryConnection(); + await Promise.resolve(); + }); + expect(latestContext!.transcriptPhase).toBe("ready"); + expect(latestContext!.transcriptItems.at(-1)).toMatchObject({ + text: "Recovered before roster", + }); + await act(async () => { + roster.resolve({ sessions: [] }); + await recovery; + }); + expect(latestContext!.connection.presence).toBe("online"); + }); + + it("renders a ready latest body while the full directory is still pending", async () => { + const directory = deferred(); + const base = mocks.call.getMockImplementation()!; + const preview = { + sessionId: "slow-history", + historyDeferred: true, + rounds: { items: [{ id: "recent" }], complete: false }, + snapshot: { + sessionId: "slow-history", + roundId: "recent", + version: 1, + snapshotDelta: false, + upserts: [ + { + id: "reply", + source: "assistant", + displayVariant: "message", + displayText: "Ready before directory", + }, + ], + }, + }; + mocks.call.mockImplementation( + (method: string, params?: Record) => { + if ( + method === "session/subscribe" && + params?.sessionId === "slow-history" + ) + return Promise.resolve(preview); + if (method === "session/history") return directory.promise; + return base(method, params); + } + ); + await act(async () => { + await latestContext!.subscribeSession("slow-history"); + }); + expect(latestContext!.transcriptPhase).toBe("ready"); + expect(latestContext!.transcriptItems).toHaveLength(1); + expect(latestContext!.transcriptRoundsComplete).toBe(false); + await act(async () => { + directory.resolve({ + ...preview, + historyDeferred: false, + rounds: { items: [{ id: "older" }, { id: "recent" }], complete: true }, + }); + await directory.promise; + }); + expect(latestContext!.transcriptRoundsComplete).toBe(true); + expect(latestContext!.transcriptItems).toHaveLength(1); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the browser platform identity in the initialize wire shape", () => { + expect(mocks.call).toHaveBeenCalledWith("initialize", { + protocolVersion: 1, + clientInfo: { name: "orgii-mobile-pwa", version: "0.1.0" }, + capabilities: { interactions: ["permission"], streaming: true }, + deviceLabel: "ORG2 Mobile", + }); + }); + it("negotiates read-state sync and detaches it when the paired desktop goes offline", async () => { + const original = mocks.call.getMockImplementation()!; + let visited = false; + mocks.call.mockImplementation((method, params) => { + if (method === "initialize") + return Promise.resolve({ + protocolVersion: 1, + tier: "read_only", + capabilities: { sessionReadState: true }, + }); + if (method === "session/read_state") + return Promise.resolve({ visitedIds: visited ? ["session-a"] : [] }); + return original(method, params); + }); + await act(async () => + latestContext!.connectLive({ + desktopId: "read-desktop", + wsUrl: "wss://read.example/ws", + }) + ); + await act(async () => latestContext!.readStateSync.watch(["session-a"])); + expect(latestContext!.readStateSync.getSnapshot().get("session-a")).toBe( + false + ); + const providerValue = latestContext; + await act(async () => { + visited = true; + mocks.notificationHandler!("session/read_state_changed", {}); + }); + expect(latestContext!.readStateSync.getSnapshot().get("session-a")).toBe( + true + ); + // Read updates are a list-only external-store subscription, not provider churn. + expect(latestContext).toBe(providerValue); + await act(async () => + mocks.notificationHandler!("relay/presence", { online: false }) + ); + expect(latestContext!.readStateSync.getSnapshot().size).toBe(0); + const calls = mocks.call.mock.calls.length; + await act(async () => + mocks.notificationHandler!("session/read_state_changed", {}) + ); + expect(mocks.call).toHaveBeenCalledTimes(calls); + }); + + it("clears navigation drafts on revoked close without discarding another desktop", async () => { + if (!latestContext || !latestDraftStore) + throw new Error("Provider did not supply draft owner"); + const scope = mobileComposerDesktopScope( + latestContext.connectionConfig, + latestContext.connection + ); + const draft = latestDraftStore.scope(JSON.stringify([scope, "session-a"])); + const other = latestDraftStore.scope( + JSON.stringify(["another desktop", "session-a"]) + ); + draft.update((value) => ({ ...value, text: "private draft" })); + other.update((value) => ({ ...value, text: "unrelated" })); + const token = draft.capture(); + await act(async () => + FakeWebSocket.instances + .at(-1)! + .dispatchEvent(new CloseEvent("close", { code: 1008 })) + ); + expect(draft.getSnapshot().text).toBe(""); + expect(other.getSnapshot().text).toBe("unrelated"); + draft.updateIfCurrent(token, (value) => ({ ...value, text: "late reply" })); + expect(draft.getSnapshot().text).toBe(""); + }); + + it("clears the owning draft store on teardown and rejects pending completions", async () => { + if (!latestDraftStore) + throw new Error("Provider did not supply draft owner"); + const draft = latestDraftStore.scope("session-a"); + draft.update((value) => ({ ...value, text: "private draft" })); + const token = draft.capture(); + await act(async () => root.render(null)); + expect(draft.getSnapshot().text).toBe(""); + draft.updateIfCurrent(token, (value) => ({ ...value, text: "stale" })); + expect(draft.getSnapshot().text).toBe(""); + }); + + it("replaces and clears draft owners when identity or endpoint changes without a parent key", async () => { + const platform = createBrowserMobileRemotePlatform(); + const renderIdentity = (authUserId: string, relayUrl: string) => + act(async () => + root.render( + React.createElement( + TestMobileRemotePlatformProvider, + { platform }, + React.createElement( + TestMobileRemoteProviders, + { + authUserId, + relayUrl, + demoByDefault: false, + suppressInitialBootstrap: true, + }, + React.createElement(Probe) + ) + ) + ) + ); + await renderIdentity("user-a", "wss://one.example/ws"); + if (!latestDraftStore) throw new Error("Missing draft owner"); + const oldDraft = latestDraftStore.scope("session-a"); + oldDraft.update((value) => ({ ...value, text: "first account" })); + await renderIdentity("user-b", "wss://one.example/ws"); + expect(oldDraft.getSnapshot().text).toBe(""); + const nextDraft = latestDraftStore.scope("session-a"); + expect(nextDraft.getSnapshot().text).toBe(""); + nextDraft.update((value) => ({ ...value, text: "second account" })); + await renderIdentity("user-b", "wss://two.example/ws"); + expect(nextDraft.getSnapshot().text).toBe(""); + expect(latestDraftStore.scope("session-a").getSnapshot().text).toBe(""); + }); + + async function mountBootstrappingProvider( + load: ReturnType< + typeof createBrowserMobileRemotePlatform + >["connection"]["load"], + listPairedDesktops = vi.fn().mockResolvedValue([]), + save?: ReturnType< + typeof createBrowserMobileRemotePlatform + >["connection"]["save"] + ) { + act(() => root.unmount()); + root = createRoot(container); + const browser = createBrowserMobileRemotePlatform(); + await act(async () => + root.render( + React.createElement( + TestMobileRemotePlatformProvider, + { + platform: { + ...browser, + connection: { + ...browser.connection, + load, + listPairedDesktops, + ...(save ? { save } : {}), + }, + }, + }, + React.createElement( + TestMobileRemoteProviders, + { authUserId: "user-a", demoByDefault: false }, + React.createElement(Probe) + ) + ) + ) + ); + } + + it("retries the selected pairing once without clearing its stored credentials", async () => { + const config = { + wsUrl: "wss://retry.example/ws", + deviceToken: "paired-credential", + }; + const save = vi.fn().mockResolvedValue(undefined); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue(config), + undefined, + save + ); + const previousCount = FakeWebSocket.instances.length; + let first!: Promise; + await act(async () => { + first = latestContext!.retryConnection(); + expect(latestContext!.retryConnection()).toBe(first); + await first; + }); + expect(FakeWebSocket.instances).toHaveLength(previousCount + 1); + expect(latestContext!.connectionConfig).toMatchObject(config); + expect(save.mock.calls.every(([, value]) => value !== null)).toBe(true); + expect(latestContext!.connection.status).toBe("connected"); + }); + + it("re-reads scoped storage after a bootstrap read failure", async () => { + const load = vi + .fn() + .mockRejectedValueOnce(new Error("locked")) + .mockResolvedValue({ wsUrl: "wss://recovered.example/ws" }); + await mountBootstrappingProvider(load); + expect(latestContext!.connection.status).toBe("error"); + await act(async () => { + expect(await latestContext!.retryConnection()).toBe(true); + }); + expect(load).toHaveBeenCalledTimes(2); + expect(load).toHaveBeenLastCalledWith("user-a"); + expect(latestContext!.connection.status).toBe("connected"); + }); + + it("ignores a recovered stored pairing after an explicit disconnect", async () => { + const restored = deferred(); + const load = vi + .fn() + .mockRejectedValueOnce(new Error("locked")) + .mockImplementationOnce(() => restored.promise); + await mountBootstrappingProvider(load); + let retry!: Promise; + const socketCount = FakeWebSocket.instances.length; + await act(async () => { + retry = latestContext!.retryConnection(); + }); + await act(async () => latestContext!.disconnect()); + await act(async () => { + restored.resolve({ wsUrl: "wss://stale.example/ws" }); + await retry; + }); + expect(latestContext!.connection.status).toBe("disconnected"); + expect(latestContext!.connectionConfig).toBeNull(); + expect(FakeWebSocket.instances).toHaveLength(socketCount); + }); + + it("retains rows and retries a failed presence recovery through the bounded reconnect owner", async () => { + vi.useFakeTimers(); + try { + const original = mocks.call.getMockImplementation()!; + const row = { id: "retained", name: "Retained", status: "idle" }; + const list = vi + .fn() + .mockResolvedValue({ sessions: [row], hasMore: false }); + mocks.call.mockImplementation((method, params) => + method === "session/list" ? list() : original(method, params) + ); + await act(async () => latestContext!.refreshSessions()); + list.mockRejectedValueOnce(new Error("Desktop warming up")); + const socketCount = FakeWebSocket.instances.length; + await act(async () => + mocks.notificationHandler!("relay/presence", { online: false }) + ); + await act(async () => + mocks.notificationHandler!("relay/presence", { online: true }) + ); + expect(latestContext!.sessions).toEqual([row]); + expect(latestContext!.connection.presence).toBe("offline"); + expect(FakeWebSocket.instances).toHaveLength(socketCount); + await act(async () => vi.advanceTimersByTimeAsync(1500)); + expect(FakeWebSocket.instances).toHaveLength(socketCount + 1); + expect(latestContext!.connection.presence).toBe("online"); + expect(latestContext!.sessions).toEqual([row]); + const calls = list.mock.calls.length; + await act(async () => vi.advanceTimersByTimeAsync(60000)); + expect(list).toHaveBeenCalledTimes(calls); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores an old presence-recovery failure after switching desktops", async () => { + const original = mocks.call.getMockImplementation()!; + const oldRead = deferred(); + const list = vi + .fn() + .mockReturnValueOnce(oldRead.promise) + .mockResolvedValue({ + sessions: [{ id: "new-desktop", name: "New", status: "idle" }], + hasMore: false, + }); + mocks.call.mockImplementation((method, params) => + method === "session/list" ? list() : original(method, params) + ); + await act(async () => + mocks.notificationHandler!("relay/presence", { online: false }) + ); + await act(async () => + mocks.notificationHandler!("relay/presence", { online: true }) + ); + await act(async () => + latestContext!.connectLive({ wsUrl: "wss://new.example/ws" }) + ); + const closes = mocks.close.mock.calls.length; + await act(async () => oldRead.reject(new Error("Old actor closed"))); + expect(mocks.close).toHaveBeenCalledTimes(closes); + expect(latestContext!.connection.presence).toBe("online"); + expect(latestContext!.sessions.map((row) => row.id)).toEqual([ + "new-desktop", + ]); + }); + + it("reports a failed explicit connection reset instead of silently returning to welcome", async () => { + const save = vi + .fn() + .mockImplementation((_user, config) => + config + ? Promise.resolve() + : Promise.reject(new Error("storage unavailable")) + ); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue({ wsUrl: "wss://retry.example/ws" }), + undefined, + save + ); + await act(async () => { + await expect(latestContext!.disconnect()).rejects.toThrow( + "storage unavailable" + ); + }); + expect(latestContext!.connection.status).toBe("error"); + expect(latestContext!.connection.error?.message).toBe( + "storage unavailable" + ); + }); + + it("publishes initialized desktop metadata and persists it for reload without changing credentials", async () => { + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "initialize" + ? Promise.resolve({ + protocolVersion: 1, + tier: "full", + desktopId: "different-process-id", + desktopIdentity: { + name: "Work Mac", + model: "Mac14,7", + username: "alex", + }, + }) + : original(method, params) + ); + const config = { + desktopId: "stable-desktop", + wsUrl: "wss://metadata.example/ws", + deviceLabel: "My iPhone", + deviceToken: "secret", + }; + await act(async () => latestContext!.connectLive(config)); + expect(latestContext!.connection.desktopName).toBe("Work Mac"); + expect( + latestContext!.pairedDesktops.find( + (desktop) => desktop.id === config.desktopId + ) + ).toMatchObject({ + name: "Work Mac", + desktopIdentity: { model: "Mac14,7", username: "alex" }, + }); + expect(loadScopedMobileConnectionConfig("user-a", localStorage)).toEqual({ + ...config, + desktopIdentity: { name: "Work Mac", model: "Mac14,7", username: "alex" }, + }); + }); + + it("does not fail the connection when metadata persistence fails", async () => { + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "initialize" + ? Promise.resolve({ + protocolVersion: 1, + desktopIdentity: { name: "Available Mac", username: "alex" }, + }) + : original(method, params) + ); + const save = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValue(new Error("keychain unavailable")); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue({ wsUrl: "wss://metadata-fail.example/ws" }), + vi.fn().mockResolvedValue([]), + save + ); + expect(save).toHaveBeenCalledTimes(2); + expect(latestContext!.connection.status).toBe("connected"); + expect(latestContext!.connection.desktopName).toBe("Available Mac"); + expect(latestContext!.connectionConfig?.desktopIdentity?.username).toBe( + "alex" + ); + expect(latestContext!.pairedDesktops[0]).toMatchObject({ + name: "Available Mac", + desktopIdentity: { username: "alex" }, + }); + }); + + it("does not let a slow pre-initialize inventory erase the confirmed name", async () => { + const oldInventory = deferred(); + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "initialize" + ? Promise.resolve({ + protocolVersion: 1, + desktopIdentity: { name: "Confirmed Mac" }, + }) + : original(method, params) + ); + const inventory = vi + .fn() + .mockResolvedValueOnce([]) + .mockImplementationOnce(() => oldInventory.promise) + .mockResolvedValue([ + { + id: "confirmed", + name: "Confirmed Mac", + active: true, + updatedAtMs: 1, + desktopIdentity: { name: "Confirmed Mac" }, + }, + ]); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue({ + desktopId: "confirmed", + wsUrl: "wss://confirmed.example/ws", + }), + inventory + ); + await act(async () => { + oldInventory.resolve([]); + }); + expect(latestContext!.pairedDesktops[0].name).toBe("Confirmed Mac"); + }); + + it("retries unchanged desktop metadata after a failed save on reconnect", async () => { + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "initialize" + ? Promise.resolve({ + protocolVersion: 1, + desktopIdentity: { name: "Retry Mac" }, + }) + : original(method, params) + ); + const save = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("locked")) + .mockResolvedValue(undefined); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue({ wsUrl: "wss://retry-metadata.example/ws" }), + vi.fn().mockResolvedValue([]), + save + ); + expect(save).toHaveBeenCalledTimes(2); + const cached = latestContext!.connectionConfig!; + await act(async () => latestContext!.connectLive(cached)); + expect(save).toHaveBeenCalledTimes(4); + expect(save.mock.calls[3][1]).toMatchObject({ + desktopIdentity: { name: "Retry Mac" }, + }); + expect(latestContext!.connection.status).toBe("connected"); + }); + + it("ignores an old desktop identity arriving after another computer was selected", async () => { + const old = deferred(); + const original = mocks.call.getMockImplementation()!; + let initializes = 0; + mocks.call.mockImplementation((method, params) => + method === "initialize" + ? ++initializes === 1 + ? old.promise + : Promise.resolve({ + protocolVersion: 1, + desktopIdentity: { name: "New Mac" }, + }) + : original(method, params) + ); + let first!: Promise; + await act(async () => { + first = latestContext! + .connectLive({ desktopId: "old", wsUrl: "wss://old.example/ws" }) + .catch(() => undefined); + }); + await act(async () => + latestContext!.connectLive({ + desktopId: "new", + wsUrl: "wss://new.example/ws", + }) + ); + await act(async () => { + old.resolve({ protocolVersion: 1, desktopIdentity: { name: "Old Mac" } }); + await first; + }); + expect(latestContext!.connection.desktopName).toBe("New Mac"); + expect( + loadScopedMobileConnectionConfig("user-a", localStorage)?.desktopIdentity + ?.name + ).toBe("New Mac"); + }); + + it("finishes local bootstrap before a slow handshake or device inventory", async () => { + const handshake = deferred<{ protocolVersion: number; tier: string }>(); + const inventory = deferred(); + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "initialize" ? handshake.promise : original(method, params) + ); + const config = { wsUrl: "wss://restored.example.test/v1/mobile/ws" }; + mocks.call.mockClear(); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue(config), + vi.fn(() => inventory.promise) + ); + expect(latestContext?.bootstrapPending).toBe(false); + expect(latestContext?.connectionConfig).toEqual(config); + expect(latestContext?.connection.status).not.toBe("connected"); + expect(mocks.call).toHaveBeenCalledWith("initialize", expect.any(Object)); + await act(async () => { + handshake.resolve({ protocolVersion: 1, tier: "full" }); + }); + // Neither initial inventory nor the post-save inventory ever resolves. + expect(latestContext?.connection.status).toBe("connected"); + }); + + it("keeps bootstrap pending only while stored configuration is loading", async () => { + const stored = deferred(); + await mountBootstrappingProvider(vi.fn(() => stored.promise)); + expect(latestContext?.bootstrapPending).toBe(true); + expect(latestContext?.connectionConfig).toBeNull(); + const socketCount = FakeWebSocket.instances.length; + await act(async () => stored.resolve(null)); + expect(latestContext?.bootstrapPending).toBe(false); + expect(latestContext?.connectionConfig).toBeNull(); + expect(FakeWebSocket.instances).toHaveLength(socketCount); + }); + + it("durably completes pairing at Relay approval even when Desktop is offline, then reloads without SAS", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const original = mocks.call.getMockImplementation()!; + let online = false; + mocks.call.mockImplementation((method, params) => + method === "initialize" && !online + ? Promise.reject(new Error("desktop is offline")) + : original(method, params) + ); + const config: MobileConnectionConfig = { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + desktopId: "paired-restart-test", + deviceToken: "durable-device-credential", + deviceLabel: "My phone", + pairingCode: "ONE-TIME", + }; + let result: Promise | undefined; + await act(async () => { + result = latestContext?.connectLive(config).catch((error) => error); + }); + expect(latestContext?.connectionConfig?.pairingCode).toBe("ONE-TIME"); + await act(async () => { + mocks.notificationHandler?.("pairing/approved", {}); + await result; + }); + const restored = loadScopedMobileConnectionConfig("user-a", localStorage); + expect(restored).toEqual({ + wsUrl: config.wsUrl, + desktopId: config.desktopId, + deviceToken: config.deviceToken, + deviceLabel: config.deviceLabel, + }); + expect(latestContext?.connection.status).toBe("connecting"); + expect(latestContext?.connection.presence).toBe("offline"); + + // A new app process uses the real stored credential, not the QR payload. + await mountBootstrappingProvider(() => Promise.resolve(restored)); + expect(latestContext?.connection.status).toBe("connecting"); + online = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + expect(latestContext?.connection.status).toBe("connected"); + expect(latestContext?.connectionConfig?.pairingCode).toBeUndefined(); + expect(latestContext?.connectionConfig?.deviceToken).toBe( + config.deviceToken + ); + } finally { + vi.useRealTimers(); + } + }); + + it("recovers a saved device when Desktop returns, with no repeated attempts while hidden", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let hidden = false; + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden, + }); + try { + const original = mocks.call.getMockImplementation()!; + let online = false; + mocks.call.mockImplementation((method, params) => + method === "initialize" && !online + ? Promise.reject(new Error("desktop is offline")) + : original(method, params) + ); + const config = { + wsUrl: "wss://restored.example.test/v1/mobile/ws", + deviceToken: "saved", + }; + await mountBootstrappingProvider(vi.fn().mockResolvedValue(config)); + expect(latestContext?.connection.status).toBe("connecting"); + expect(latestContext?.connectionConfig).toEqual(config); + const initialCount = FakeWebSocket.instances.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + expect(FakeWebSocket.instances).toHaveLength(initialCount + 1); + hidden = true; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(FakeWebSocket.instances).toHaveLength(initialCount + 1); + online = true; + hidden = false; + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(FakeWebSocket.instances).toHaveLength(initialCount + 2); + expect(latestContext?.connection.status).toBe("connected"); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(FakeWebSocket.instances).toHaveLength(initialCount + 2); + } finally { + Reflect.deleteProperty(document, "hidden"); + vi.useRealTimers(); + } + }); + + it("cancels an offline device retry without deleting the paired inventory", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const config = { + wsUrl: "wss://retry.example.test/v1/mobile/ws", + desktopId: "cancel-retry", + deviceToken: "saved", + }; + mocks.call.mockRejectedValue(new Error("desktop is offline")); + await mountBootstrappingProvider(vi.fn().mockResolvedValue(config)); + const count = FakeWebSocket.instances.length; + await act(async () => { + await latestContext?.disconnect(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(FakeWebSocket.instances).toHaveLength(count); + expect(latestContext?.connection.status).toBe("disconnected"); + expect( + await createBrowserMobileRemotePlatform().connection.listPairedDesktops( + "user-a" + ) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "cancel-retry", active: false }), + ]) + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not confirm or automatically retry an expired first pairing", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const config = { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + pairingCode: "EXPIRED", + deviceToken: "pending", + }; + let result: Promise | undefined; + await act(async () => { + result = latestContext?.connectLive(config).catch((error) => error); + }); + const count = FakeWebSocket.instances.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(130_001); + }); + expect(await result).toBeInstanceOf(Error); + expect(latestContext?.connection.status).toBe("error"); + expect( + loadScopedMobileConnectionConfig("user-a", localStorage)?.pairingCode + ).toBe("EXPIRED"); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(FakeWebSocket.instances).toHaveLength(count); + } finally { + vi.useRealTimers(); + } + }); + + it("does not retry authorization denied before initialize completes", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + if (!latestContext || !latestDraftStore) + throw new Error("Missing draft owner"); + const config = { wsUrl: "wss://relay.example.test/v1/mobile/ws" }; + const draft = latestDraftStore.scope( + JSON.stringify([mobileComposerDesktopScope(config, {}), "session-a"]) + ); + draft.update((value) => ({ ...value, text: "draft before reconnect" })); + const handshake = deferred(); + mocks.call.mockImplementation(() => handshake.promise); + let result: Promise | undefined; + await act(async () => { + result = latestContext + ?.connectLive({ wsUrl: "wss://relay.example.test/v1/mobile/ws" }) + .catch((error) => error); + }); + const socket = FakeWebSocket.instances.at(-1)!; + const count = FakeWebSocket.instances.length; + await act(async () => { + socket.readyState = FakeWebSocket.CLOSED; + socket.dispatchEvent(new CloseEvent("close", { code: 1008 })); + handshake.reject(new Error("WebSocket closed")); + await result; + }); + expect(latestContext?.connection.status).toBe("error"); + expect(draft.getSnapshot().text).toBe(""); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(FakeWebSocket.instances).toHaveLength(count); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["ticket-denied", "initialize-denied", "network-error"])( + "handles %s at the draft authority boundary", + async (failure) => { + const browser = createBrowserMobileRemotePlatform(); + const config = { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + desktopId: "desktop-one", + }; + const error = + failure === "network-error" + ? new Error("network unavailable") + : new MobileConnectionAuthorizationError("Pairing revoked"); + const originalCall = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + failure === "initialize-denied" && method === "initialize" + ? Promise.reject(error) + : originalCall(method, params) + ); + const platform = { + ...browser, + connection: { + ...browser.connection, + prepareSocketUrl: async () => { + if (failure !== "initialize-denied") throw error; + return config.wsUrl; + }, + }, + }; + await act(async () => + root.render( + React.createElement( + TestMobileRemotePlatformProvider, + { platform }, + React.createElement( + TestMobileRemoteProviders, + { + authUserId: "user-a", + demoByDefault: false, + suppressInitialBootstrap: true, + }, + React.createElement(Probe) + ) + ) + ) + ); + if (!latestDraftStore || !latestContext) + throw new Error("Missing draft owner"); + const draft = latestDraftStore.scope( + JSON.stringify([mobileComposerDesktopScope(config, {}), "session-a"]) + ); + draft.update((value) => ({ ...value, text: "keep only if transient" })); + await act(async () => { + await latestContext!.connectLive(config).catch(() => undefined); + }); + expect(draft.getSnapshot().text).toBe( + failure === "network-error" ? "keep only if transient" : "" + ); + } + ); + + it("releases bootstrap and reports a failed configuration read", async () => { + const stored = deferred(); + await mountBootstrappingProvider(vi.fn(() => stored.promise)); + expect(latestContext?.bootstrapPending).toBe(true); + await act(async () => + stored.reject(new Error("secure storage unavailable")) + ); + expect(latestContext?.bootstrapPending).toBe(false); + expect(latestContext?.connectionConfig).toBeNull(); + expect(latestContext?.connection.status).toBe("error"); + }); + + it("retries a failed approval save after Desktop recovers without requiring metadata", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const browser = createBrowserMobileRemotePlatform(); + const save = vi.fn(browser.connection.save); + save + .mockImplementationOnce(browser.connection.save) + .mockRejectedValueOnce( + new Error("secure storage temporarily unavailable") + ); + const original = mocks.call.getMockImplementation()!; + let online = false; + mocks.call.mockImplementation((method, params) => + method === "initialize" && !online + ? Promise.reject(new Error("desktop is offline")) + : original(method, params) + ); + const config = { + wsUrl: "wss://relay.example.test/v1/mobile/ws", + desktopId: "approval-save-retry", + deviceToken: "saved", + pairingCode: "once", + }; + await mountBootstrappingProvider( + vi.fn().mockResolvedValue(config), + undefined, + save + ); + await act(async () => { + mocks.notificationHandler?.("pairing/approved", {}); + }); + expect(save).toHaveBeenCalledTimes(2); + expect( + loadScopedMobileConnectionConfig("user-a", localStorage)?.pairingCode + ).toBe("once"); + online = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + expect(latestContext?.connection.status).toBe("connected"); + expect(save).toHaveBeenCalledTimes(3); + expect( + loadScopedMobileConnectionConfig("user-a", localStorage)?.pairingCode + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not persist a late approval after the user disconnects", async () => { + let connecting: Promise | undefined; + await act(async () => { + connecting = latestContext + ?.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + pairingCode: "once", + deviceToken: "pending", + }) + .catch((error) => error); + }); + await act(async () => { + mocks.notificationHandler?.("pairing/approved", {}); + await latestContext?.disconnect(); + await connecting; + }); + expect(latestContext?.connection.status).toBe("disconnected"); + expect(latestContext?.connectionConfig).toBeNull(); + expect(loadScopedMobileConnectionConfig("user-a", localStorage)).toBeNull(); + }); + + it("ignores a hidden attempt's late initialize after a visible recovery succeeds", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let hidden = false; + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden, + }); + try { + const handshake = deferred<{ protocolVersion: number }>(); + mocks.call.mockImplementationOnce(() => handshake.promise); + let connecting: Promise | undefined; + await act(async () => { + connecting = latestContext + ?.connectLive({ wsUrl: "wss://relay.example.test/v1/mobile/ws" }) + .catch((error) => error); + }); + hidden = true; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + hidden = false; + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(latestContext?.connection.status).toBe("connected"); + const count = FakeWebSocket.instances.length; + await act(async () => { + handshake.resolve({ protocolVersion: 1 }); + await connecting; + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(latestContext?.connection.status).toBe("connected"); + expect(latestContext?.connection.presence).toBe("online"); + expect(FakeWebSocket.instances).toHaveLength(count); + } finally { + Reflect.deleteProperty(document, "hidden"); + vi.useRealTimers(); + } + }); - beforeAll(() => { - actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; - vi.stubGlobal("WebSocket", FakeWebSocket); + it("reports a saved-device persistence failure instead of staying connecting", async () => { + const config = { wsUrl: "wss://restored.example.test/v1/mobile/ws" }; + const save = vi + .fn() + .mockRejectedValue(new Error("secure storage write failed")); + mocks.call.mockClear(); + await mountBootstrappingProvider( + vi.fn().mockResolvedValue(config), + undefined, + save + ); + expect(save).toHaveBeenCalled(); + expect(latestContext?.bootstrapPending).toBe(false); + expect(latestContext?.connection.status).toBe("error"); + expect(mocks.call).not.toHaveBeenCalledWith( + "initialize", + expect.any(Object) + ); }); - beforeEach(async () => { - latestContext = null; - sendResult = deferred<{ execution: string }>(); - mocks.sendParams = null; - mocks.externalRoundId = null; - mocks.notificationHandler = null; - mocks.close.mockReset(); - FakeWebSocket.instances = []; - FakeWebSocket.deferCloseEvents = false; - FakeWebSocket.pendingCloseEvents = []; - mocks.call - .mockReset() - .mockImplementation( - (method: string, params?: Record) => { - if (method === "initialize") { - return Promise.resolve({ protocolVersion: 1, tier: "full" }); - } - if (method === "session/list") { - return Promise.resolve({ sessions: [] }); - } - if (method === "session/subscribe") { - const externalRound = mocks.externalRoundId - ? [ - { - id: mocks.externalRoundId, - userPreview: "external turn", - }, - ] - : []; - return Promise.resolve({ - sessionId: params?.sessionId, - rounds: { - items: [ - { id: "round-1", userPreview: "First" }, - { id: "round-2", userPreview: "Second" }, - { id: "round-3", userPreview: "Latest" }, - ...externalRound, - ], - complete: true, - }, - snapshot: { - sessionId: params?.sessionId, - roundId: mocks.externalRoundId ?? "round-3", - version: 1, - snapshotDelta: false, - upserts: [], - }, - }); - } - if (method === "session/round") { - const roundId = String(params?.roundId); - return Promise.resolve({ - sessionId: params?.sessionId, - roundId, - snapshot: { - sessionId: params?.sessionId, - roundId, - version: 1, - snapshotDelta: false, - upserts: [ - { - id: `agent-${roundId}`, - source: "assistant", - displayVariant: "message", - displayText: `History ${roundId}`, - }, - ], - }, - }); - } - if (method === "session/send") { - mocks.sendParams = params ?? null; - return sendResult.promise; - } - return Promise.resolve({}); - } - ); + it("observes revocation while the initial roster is still loading", async () => { + const roster = deferred<{ sessions: never[] }>(); + const original = mocks.call.getMockImplementation()!; + mocks.call.mockImplementation((method, params) => + method === "session/list" ? roster.promise : original(method, params) + ); + let connecting: Promise | undefined; + await act(async () => { + connecting = latestContext?.connectLive({ + wsUrl: "wss://relay.example.test/v1/mobile/ws", + }); + }); + const socket = FakeWebSocket.instances.at(-1)!; + await act(async () => { + socket.readyState = FakeWebSocket.CLOSED; + socket.dispatchEvent(new CloseEvent("close", { code: 1008 })); + }); + expect(latestContext?.connection.status).toBe("error"); + await act(async () => { + roster.resolve({ sessions: [] }); + await connecting; + }); + expect(latestContext?.connection.status).toBe("error"); + }); - container = document.createElement("div"); - document.body.appendChild(container); + it("keeps the newest desktop choice when configuration reads finish out of order", async () => { + act(() => root.unmount()); root = createRoot(container); - await act(async () => { + const first = deferred<{ wsUrl: string }>(); + const browser = createBrowserMobileRemotePlatform(); + const platform = { + ...browser, + connection: { + ...browser.connection, + selectPairedDesktop: vi.fn((_user: string, desktop: string) => + desktop === "a" + ? first.promise + : Promise.resolve({ wsUrl: "wss://b.example/v1/mobile/ws" }) + ), + }, + }; + await act(async () => root.render( React.createElement( TestMobileRemotePlatformProvider, - { platform: createBrowserMobileRemotePlatform() }, + { platform }, React.createElement( TestMobileRemoteProviders, { @@ -235,36 +1520,108 @@ describe("MobileRemoteProviders send lifecycle", () => { React.createElement(Probe) ) ) - ); - }); + ) + ); + let a!: Promise; await act(async () => { - await latestContext?.connectLive({ - wsUrl: "wss://relay.example.test/v1/mobile/ws", - }); + a = latestContext!.switchPairedDesktop("a"); }); + await act(async () => latestContext!.switchPairedDesktop("b")); + const socketCount = FakeWebSocket.instances.length; await act(async () => { - await latestContext?.subscribeSession("session-a"); + first.resolve({ wsUrl: "wss://a.example/v1/mobile/ws" }); + await a; }); + expect(latestContext!.connectionConfig?.wsUrl).toBe( + "wss://b.example/v1/mobile/ws" + ); + expect(FakeWebSocket.instances).toHaveLength(socketCount); }); - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.clearAllMocks(); - }); - - afterAll(() => { - vi.unstubAllGlobals(); - Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + it("loads additional workspace rows, preserves the loaded window on refresh and retains it on failure", async () => { + mocks.call.mockImplementation((method, params) => { + if (method !== "session/list") return Promise.resolve({}); + const offset = Number(params?.offset ?? 0); + return Promise.resolve({ + sessions: [ + { + id: `row-${offset}`, + name: "Row", + status: "idle", + repoPath: `/workspace/${offset}`, + updatedAtMs: offset, + }, + ], + nextOffset: offset + 50, + hasMore: offset === 0, + }); + }); + await act(async () => { + await latestContext!.refreshSessions(); + }); + expect(latestContext!.sessionsHasMore).toBe(true); + await act(async () => { + await latestContext!.loadMoreSessions(); + }); + expect(latestContext!.sessions.map((row) => row.repoPath)).toEqual([ + "/workspace/0", + "/workspace/50", + ]); + await act(async () => { + await latestContext!.refreshSessions(); + }); + expect(latestContext!.sessions).toHaveLength(2); + mocks.call.mockRejectedValueOnce(new Error("offline")); + await act(async () => { + await expect(latestContext!.refreshSessions()).rejects.toThrow("offline"); + }); + expect(latestContext!.sessions).toHaveLength(2); }); - it("keeps the browser platform identity in the initialize wire shape", () => { - expect(mocks.call).toHaveBeenCalledWith("initialize", { - protocolVersion: 1, - clientInfo: { name: "orgii-mobile-pwa", version: "0.1.0" }, - capabilities: { interactions: ["permission"], streaming: true }, - deviceLabel: "ORG2 Mobile", + it("replaces fallback rows with canonical desktop snapshots and coalesces list-change bursts", async () => { + const canonical = { + id: "desktop-current", + name: "Canonical desktop title", + status: "running", + repoName: "Project", + repoPath: "/project", + updatedAtMs: 123, + display: { cliAgentType: "codex" }, + }; + const stale = deferred<{ sessions: (typeof canonical)[] }>(); + const fresh = deferred<{ sessions: (typeof canonical)[] }>(); + const original = mocks.call.getMockImplementation()!; + const list = vi + .fn() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(fresh.promise) + .mockResolvedValue({ sessions: [], hasMore: false }); + mocks.call.mockImplementation((method, params) => + method === "session/list" ? list() : original(method, params) + ); + const previous = latestContext!.sessions; + await act(async () => { + for (let i = 0; i < 20; i++) { + mocks.notificationHandler!("session/list_changed", {}); + } + }); + expect(list).toHaveBeenCalledTimes(1); + expect(latestContext!.sessions).toEqual(previous); + await act(async () => { + stale.resolve({ sessions: [{ ...canonical, name: "Stale title" }] }); + }); + expect(list).toHaveBeenCalledTimes(2); + expect(latestContext!.sessions).toEqual(previous); + await act(async () => { + fresh.resolve({ sessions: [canonical] }); + }); + expect(latestContext!.sessions).toEqual([canonical]); + expect(latestContext!.sessionsHasMore).toBe(false); + await act(async () => { + mocks.notificationHandler!("session/list_changed", {}); }); + expect(latestContext!.sessions).toEqual([]); + expect(list).toHaveBeenCalledTimes(3); }); it("observes revocation while the initial roster is still loading", async () => { @@ -714,14 +2071,47 @@ describe("MobileRemoteProviders send lifecycle", () => { }); }); - it("keeps session lifecycle callbacks stable across presence changes", () => { + it("reloads the roster once when Desktop returns on the surviving relay socket", async () => { + const original = mocks.call.getMockImplementation()!; + const list = vi.fn().mockResolvedValue({ + sessions: [ + { id: "after-desktop-restart", name: "Recovered", status: "idle" }, + ], + hasMore: false, + }); + mocks.call.mockImplementation((method, params) => + method === "session/list" ? list() : original(method, params) + ); + const socketCount = FakeWebSocket.instances.length; + const notify = mocks.notificationHandler!; + const subscriptions = mocks.call.mock.calls.filter( + ([method]) => method === "session/subscribe" + ).length; + await act(async () => notify("relay/presence", { online: false })); + expect(list).not.toHaveBeenCalled(); + await act(async () => { + notify("relay/presence", { online: true }); + notify("relay/presence", { online: true }); + }); + expect(list).toHaveBeenCalledTimes(1); + expect(latestContext!.sessions.map((row) => row.id)).toEqual([ + "after-desktop-restart", + ]); + expect(FakeWebSocket.instances).toHaveLength(socketCount); + expect( + mocks.call.mock.calls.filter(([method]) => method === "session/subscribe") + ).toHaveLength(subscriptions + 1); + expect(mocks.sendParams).toBeNull(); + }); + + it("keeps session lifecycle callbacks stable across presence changes", async () => { const initialSubscribe = latestContext?.subscribeSession; const initialUnsubscribe = latestContext?.unsubscribeSession; - act(() => { + await act(async () => { mocks.notificationHandler?.("relay/presence", { online: false }); }); - act(() => { + await act(async () => { mocks.notificationHandler?.("relay/presence", { online: true }); }); @@ -904,8 +2294,9 @@ describe("MobileRemoteProviders send lifecycle", () => { (latestContext as MobileRemoteContextValue | null)?.connectionConfig ).toEqual(explicitConfig); expect(FakeWebSocket.instances).toHaveLength(1); - expect(save).toHaveBeenCalledTimes(1); - expect(save).toHaveBeenCalledWith("user-a", explicitConfig); + expect(save).toHaveBeenCalledTimes(2); + expect(save).toHaveBeenNthCalledWith(1, "user-a", explicitConfig); + expect(save).toHaveBeenNthCalledWith(2, "user-a", explicitConfig); }); it("serializes connection persistence so the latest disconnect wins", async () => { @@ -1500,11 +2891,12 @@ describe("MobileRemoteProviders send lifecycle", () => { }); await act(async () => { respondResult.reject(new Error("Relay rejected the answer")); - await expect(pending).rejects.toThrow("Relay rejected the answer"); + await expect(pending).resolves.toBeUndefined(); }); expect(latestContext?.permissionSubmitting).toBe(false); expect(latestContext?.activePermission?.requestId).toBe("hookperm-retry"); + expect(latestContext?.permissionFailed).toBe(true); }); it("hides a prompt raised for another session and dismisses a stale head", () => { diff --git a/src/modules/MobileRemote/app/MobileRemoteProviders.tsx b/src/modules/MobileRemote/app/MobileRemoteProviders.tsx index 3013e8e14a..96e5b3c585 100644 --- a/src/modules/MobileRemote/app/MobileRemoteProviders.tsx +++ b/src/modules/MobileRemote/app/MobileRemoteProviders.tsx @@ -1,51 +1,33 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; -import { MobileAuthContext } from "../auth/MobileAuthContext"; -import type { MobileRpcClient } from "../connection/mobileRpcClient"; -import { createRemoteReconnectController } from "../connection/remoteReconnectController"; -import type { - MobileConnectionConfig, - MobileConnectionState, - MobilePairedDesktopSummary, -} from "../connection/types"; -import { DEMO_PERMISSION_REQUEST } from "../demo/demoFixtures"; -import { useMobileRemotePlatform } from "../platform"; +import { createLogger } from "@src/hooks/logger"; + +import { MobileComposerDraftContext } from "../components/composer/MobileComposerDraftContext"; +import { mobileComposerDesktopScope } from "../components/composer/mobileComposerDraftStore"; +import { toMobileRpcError } from "../connection/mobileRpcClient"; +import { MobileConnectionAuthorizationError } from "../connection/types"; +import type { MobileConnectionConfig } from "../connection/types"; +import { DEMO_DESKTOP_NAME, DEMO_SESSIONS } from "../demo/demoFixtures"; import { MobileRemoteContext, type MobileRemoteContextValue, + type MobileRemoteProvidersProps, } from "./MobileRemoteContext"; -import { useMobileConnectionActions } from "./useMobileConnectionActions"; -import { useMobileConnectionEstablish } from "./useMobileConnectionEstablish"; -import { useMobileConnectionRefs } from "./useMobileConnectionRefs"; -import { useMobileConnectionTransport } from "./useMobileConnectionTransport"; -import { useMobilePermissions } from "./useMobilePermissions"; +import { + SupersededConnectionError, + useMobileConnectionEstablish, +} from "./useMobileConnectionEstablish"; +import { useMobileRemoteState } from "./useMobileRemoteState"; import { useMobileRpcNotifications } from "./useMobileRpcNotifications"; -import { useMobileSend } from "./useMobileSend"; import { useMobileSessionActions } from "./useMobileSessionActions"; -import { useMobileSessionList } from "./useMobileSessionList"; -import { useMobileSessionModel } from "./useMobileSessionModel"; -import { useMobileTranscript } from "./useMobileTranscript"; -export type { MobileRemoteContextValue } from "./MobileRemoteContext"; export { useMobileRemote } from "./MobileRemoteContext"; -export type { MobileSendStatus } from "./useMobileSend"; +export type { + MobileRemoteContextValue, + MobileRemoteProvidersProps, +} from "./MobileRemoteContext"; -export interface MobileRemoteProvidersProps { - children: React.ReactNode; - /** Authenticated ORG2 Cloud subject; scopes all retained pairing state. */ - authUserId: string; - relayUrl?: string; - demoByDefault?: boolean; - /** A freshly scanned QR must take precedence over a stored old desktop. */ - suppressInitialBootstrap?: boolean; -} +export type { MobileSendStatus } from "./useMobileSend"; export function MobileRemoteProviders({ children, @@ -54,189 +36,419 @@ export function MobileRemoteProviders({ demoByDefault = true, suppressInitialBootstrap = false, }: MobileRemoteProvidersProps) { - const platform = useMobileRemotePlatform(); - const auth = useContext(MobileAuthContext); - const authRef = useRef(auth); - authRef.current = auth; - const refs = useMobileConnectionRefs(); + const state = useMobileRemoteState({ + authUserId, + relayUrl, + demoByDefault, + suppressInitialBootstrap, + }); const { + platform, + draftStore, + preparationRef, clientRef, + rpc, + setRpc, + socketRef, activeSessionRef, + unsubscribeRpcRef, + activeConfigRef, generationRef, selectionIntentRef, + retryFlightRef, recoverRef, + reconnect, + connectionWriteChainRef, + inventoryRevisionRef, scheduleReconnectRef, - } = refs; - const reconnect = useMemo( - () => - createRemoteReconnectController( - platform.runtime, - (generation) => generation === generationRef.current, - (config, generation) => recoverRef.current(config, generation) - ), - [platform.runtime, generationRef, recoverRef] - ); - - const [connection, setConnection] = useState({ - status: "disconnected", - presence: "unknown", - demoMode: demoByDefault && !suppressInitialBootstrap, - }); - const [connectionConfig, setConnectionConfig] = - useState(null); - const [pairedDesktops, setPairedDesktops] = useState< - MobilePairedDesktopSummary[] - >([]); - const connectionRef = useRef(connection); - connectionRef.current = connection; - const { sessions, sessionsHasMore, requestSessionList, resetSessions } = - useMobileSessionList(clientRef); - const { + connection, + setConnection, + connectionConfig, + setConnectionConfig, + bootstrapPending, + setBootstrapPending, + pairedDesktops, + setPairedDesktops, + readStateSync, + sessions, + sessionsHasMore, + resetSessions, + openingClient, + openedSession, transcript, - setTranscript, transcriptView, - beginLoad, resetTranscript, showDemoTranscript, - failLoad, invalidateTranscriptRequests, - requestSessionSnapshot, - refreshSubscribedSession, - receiveSnapshot, selectRound, retrySelectedRound, - } = useMobileTranscript({ - clientRef, - connectionRef, - activeSessionRef, - connection, - }); - const requireWritableClient = useCallback((): MobileRpcClient => { - const client = clientRef.current; - if ( - !client || - connection.status !== "connected" || - connection.presence !== "online" - ) { - throw new Error("Desktop is offline"); - } - if (connection.tier === "read_only") { - throw new Error("This device has read-only access"); - } - return client; - }, [connection.presence, connection.status, connection.tier, clientRef]); - - const { activePermission, + focusPermission, permissionQueueDepth, permissionSubmitting, + permissionFailed, respondPermission, dismissPermissionHead, resetPermissions, - receivePermissionEvent, - } = useMobilePermissions({ - sessionId: transcript.sessionId, - demoMode: connection.demoMode, - requireWritableClient, - }); - - const { + pendingInbox, sessionModel, refreshSessionModel, + loadSessionModels, setSessionModel, - resetSessionModel, - } = useMobileSessionModel({ - clientRef, - connectionRef, - activeSessionRef, - requireWritableClient, - }); - - const onDemoSend = useCallback( - (sessionId: string) => - resetPermissions([{ ...DEMO_PERMISSION_REQUEST, sessionId }]), - [resetPermissions] - ); - const { sendStatus, resetSend, - receiveTerminal, - receiveSendStatus, sendMessage, - } = useMobileSend({ - demoMode: connection.demoMode, - runtime: platform.runtime, - model: sessionModel.config?.model, - requireWritableClient, - setTranscript, - onDemoSend, - }); + } = state; + const persistConnection = useCallback( + (config: MobileConnectionConfig | null) => { + const generation = generationRef.current; + const inventoryRevision = ++inventoryRevisionRef.current; + const operation = connectionWriteChainRef.current.then(async () => { + if (generation !== generationRef.current) return; + await platform.connection.save(authUserId, config); + void platform.connection + .listPairedDesktops(authUserId) + .then((inventory) => { + if ( + generation === generationRef.current && + inventoryRevision === inventoryRevisionRef.current + ) + setPairedDesktops(inventory); + }) + .catch(() => undefined); + }); + connectionWriteChainRef.current = operation.catch(() => undefined); + return operation; + }, + [ + authUserId, + connectionWriteChainRef, + generationRef, + inventoryRevisionRef, + platform.connection, + setPairedDesktops, + ] + ); - const handleRpcNotification = useMobileRpcNotifications({ - refs, - setConnection, - requestSessionList, - receivePermissionEvent, - receiveTerminal, - receiveSendStatus, - receiveSnapshot, - refreshSubscribedSession, - }); + const clearReconnectTimer = useCallback(() => reconnect.clear(), [reconnect]); - const { - persistConnection, + const releaseTransport = useCallback( + (close: boolean) => { + reconnect.invalidate(); + preparationRef.current?.abort(); + preparationRef.current = null; + // Invalidate every in-flight subscribe/refresh from the old socket before + // it can reject or resolve into the retained logical session state. + invalidateTranscriptRequests(); + unsubscribeRpcRef.current?.(); + unsubscribeRpcRef.current = null; + const client = clientRef.current; + clientRef.current = null; + setRpc(null); + const socket = socketRef.current; + socketRef.current = null; + if (close) { + if (client) client.close(); + else socket?.close(); + } + }, + [ + reconnect, + preparationRef, + invalidateTranscriptRequests, + unsubscribeRpcRef, + clientRef, + setRpc, + socketRef, + ] + ); + + const enterDemoMode = useCallback(() => { + selectionIntentRef.current += 1; + generationRef.current += 1; + clearReconnectTimer(); + activeConfigRef.current = null; + setConnectionConfig(null); + releaseTransport(true); + setConnection({ + status: "connected", + presence: "online", + desktopName: DEMO_DESKTOP_NAME, + tier: "full", + capabilities: { roundHistory: true, openSessionFile: false }, + demoMode: true, + }); + resetSessions(DEMO_SESSIONS); + showDemoTranscript(activeSessionRef.current ?? "demo"); + resetSend(); + resetPermissions(); + }, [ + selectionIntentRef, + generationRef, clearReconnectTimer, + activeConfigRef, + setConnectionConfig, releaseTransport, - enterDemoMode, - } = useMobileConnectionTransport({ - platform, - authUserId, - refs, - reconnect, setConnection, - setConnectionConfig, - setPairedDesktops, - invalidateTranscriptRequests, - showDemoTranscript, resetSessions, + showDemoTranscript, + activeSessionRef, resetSend, resetPermissions, - }); + ]); - const { establishConnection, runReconnect } = useMobileConnectionEstablish({ - platform, + const handleRpcNotification = useMobileRpcNotifications( + state, + releaseTransport + ); + const establishConnection = useMobileConnectionEstablish( + state, authUserId, - authRef, - refs, - reconnect, - setConnection, + persistConnection, releaseTransport, - handleRpcNotification, - requestSessionList, - requestSessionSnapshot, - beginLoad, - }); + handleRpcNotification + ); + const runReconnect = useCallback( + async (config: MobileConnectionConfig, generation: number) => { + if (generation !== generationRef.current || platform.runtime.isHidden()) { + return; + } + setConnection((prev) => ({ + ...prev, + status: "connecting", + presence: "offline", + error: undefined, + })); + try { + await establishConnection(config, generation); + } catch (error) { + if ( + generation !== generationRef.current || + error instanceof SupersededConnectionError + ) + return; + const retryConfig = activeConfigRef.current; + const denied = error instanceof MobileConnectionAuthorizationError; + const retryable = !denied && retryConfig && !retryConfig.pairingCode; + if (!retryable) activeConfigRef.current = null; + setConnection((prev) => ({ + ...prev, + status: retryable ? "connecting" : "error", + presence: "offline", + error: toMobileRpcError(error), + })); + if (retryable) scheduleReconnectRef.current(retryConfig, generation); + } + }, + [ + activeConfigRef, + establishConnection, + generationRef, + platform.runtime, + scheduleReconnectRef, + setConnection, + ] + ); recoverRef.current = runReconnect; scheduleReconnectRef.current = reconnect.schedule; - const { connectLive, disconnect, switchPairedDesktop } = - useMobileConnectionActions({ - platform, - authUserId, - refs, - reconnect, - setConnection, - setConnectionConfig, + const connectLive = useCallback( + async (config: MobileConnectionConfig) => { + setBootstrapPending(false); + selectionIntentRef.current += 1; + generationRef.current += 1; + const generation = generationRef.current; + clearReconnectTimer(); + releaseTransport(true); + reconnect.reset(); + resetSessions(); + resetPermissions(); + resetSend(); + activeConfigRef.current = config; + setConnectionConfig(config); + setConnection((prev) => ({ + ...prev, + status: "connecting", + presence: "unknown", + demoMode: false, + error: undefined, + })); + let configurationSaved = false; + try { + await persistConnection(config); + configurationSaved = true; + if (generation !== generationRef.current) { + throw new Error("Connection was superseded"); + } + await establishConnection(config, generation); + } catch (error) { + if ( + generation === generationRef.current && + !(error instanceof SupersededConnectionError) + ) { + const retryConfig = activeConfigRef.current; + const retryable = + configurationSaved && + !(error instanceof MobileConnectionAuthorizationError) && + retryConfig && + !retryConfig.pairingCode; + if (!retryable) activeConfigRef.current = null; + setConnection({ + status: retryable ? "connecting" : "error", + presence: "offline", + demoMode: false, + error: toMobileRpcError(error), + }); + if (retryable) scheduleReconnectRef.current(retryConfig, generation); + } + throw error; + } + }, + [ + setBootstrapPending, + selectionIntentRef, + generationRef, clearReconnectTimer, releaseTransport, - persistConnection, - establishConnection, + reconnect, resetSessions, resetPermissions, resetSend, - resetTranscript, + activeConfigRef, + setConnectionConfig, + setConnection, + persistConnection, + establishConnection, + scheduleReconnectRef, + ] + ); + + // User-directed recovery preserves the selected pairing. It deliberately uses + // the same authenticated handshake as first connect; policy errors never loop. + const retryConnection = useCallback((): Promise => { + if (retryFlightRef.current) return retryFlightRef.current; + const intent = ++selectionIntentRef.current; + const operation = Promise.resolve().then(async () => { + if (intent !== selectionIntentRef.current) return false; + let config = connectionConfig; + if (!config) { + setBootstrapPending(true); + try { + config = relayUrl?.trim() + ? { wsUrl: relayUrl.trim() } + : await platform.connection.load(authUserId); + } catch (error) { + if (intent === selectionIntentRef.current) { + setBootstrapPending(false); + setConnection({ + status: "error", + presence: "offline", + demoMode: false, + error: toMobileRpcError(error), + }); + } + throw error; + } + if (intent !== selectionIntentRef.current) return false; + setBootstrapPending(false); + } + if (!config) { + setConnection({ + status: "disconnected", + presence: "unknown", + demoMode: false, + }); + return false; + } + await connectLive(config); + return true; }); + retryFlightRef.current = operation; + const settled = () => { + if (retryFlightRef.current === operation) retryFlightRef.current = null; + }; + void operation.then(settled, settled); + return operation; + }, [ + authUserId, + connectLive, + connectionConfig, + platform.connection, + relayUrl, + retryFlightRef, + selectionIntentRef, + setBootstrapPending, + setConnection, + ]); + + const disconnect = useCallback(async () => { + draftStore.clearDesktop( + mobileComposerDesktopScope(activeConfigRef.current, connection) + ); + setBootstrapPending(false); + selectionIntentRef.current += 1; + generationRef.current += 1; + clearReconnectTimer(); + activeConfigRef.current = null; + setConnectionConfig(null); + releaseTransport(true); + activeSessionRef.current = null; + setConnection({ + status: "disconnected", + presence: "unknown", + demoMode: false, + }); + resetSessions(); + resetTranscript(); + resetSend(); + resetPermissions(); + const generation = generationRef.current; + try { + await persistConnection(null); + } catch (error) { + if (generation === generationRef.current) { + setConnection({ + status: "error", + presence: "offline", + demoMode: false, + error: toMobileRpcError(error), + }); + } + throw error; + } + }, [ + draftStore, + activeConfigRef, + connection, + setBootstrapPending, + selectionIntentRef, + generationRef, + clearReconnectTimer, + setConnectionConfig, + releaseTransport, + activeSessionRef, + setConnection, + resetSessions, + resetTranscript, + resetSend, + resetPermissions, + persistConnection, + ]); + + const switchPairedDesktop = useCallback( + async (desktopId: string) => { + const selectionIntent = ++selectionIntentRef.current; + const config = await platform.connection.selectPairedDesktop( + authUserId, + desktopId + ); + if (selectionIntent !== selectionIntentRef.current) return; + if (!config) throw new Error("Paired desktop is unavailable"); + await connectLive(config); + }, + [authUserId, connectLive, platform.connection, selectionIntentRef] + ); const { refreshSessions, @@ -245,23 +457,43 @@ export function MobileRemoteProviders({ unsubscribeSession, openSessionFileInDesktop, stopSession, - } = useMobileSessionActions({ - connection, - connectionRef, - refs, - sessionsHasMore, - requireWritableClient, - requestSessionList, - resetSessions, - resetSend, - beginLoad, - failLoad, - showDemoTranscript, - requestSessionSnapshot, - resetTranscript, - refreshSessionModel, - resetSessionModel, - }); + } = useMobileSessionActions(state); + useEffect(() => { + const handleVisible = () => { + const config = activeConfigRef.current; + clearReconnectTimer(); + if (platform.runtime.isHidden()) { + preparationRef.current?.abort(); + if (clientRef.current || socketRef.current) { + releaseTransport(true); + setConnection((previous) => ({ + ...previous, + status: "connecting", + presence: "offline", + error: undefined, + })); + } + return; + } + if (config && !clientRef.current) { + void reconnect + .run(config, generationRef.current) + .catch((error) => logger.warn("Background operation failed", error)); + } + }; + return platform.runtime.subscribeVisibility(handleVisible); + }, [ + clearReconnectTimer, + platform.runtime, + releaseTransport, + reconnect, + activeConfigRef, + clientRef, + preparationRef, + socketRef, + setConnection, + generationRef, + ]); useEffect(() => { if (suppressInitialBootstrap) { @@ -275,23 +507,37 @@ export function MobileRemoteProviders({ let disposed = false; const bootstrapGeneration = generationRef.current; void (async () => { - const inventoryPromise = - platform.connection.listPairedDesktops(authUserId); + // Inventory is ancillary; it must not delay restoration of the selected device. + void platform.connection + .listPairedDesktops(authUserId) + .then((inventory) => { + if (!disposed && bootstrapGeneration === generationRef.current) + setPairedDesktops(inventory); + }) + .catch(() => undefined); const config = relayUrl?.trim() ? { wsUrl: relayUrl.trim() } : await platform.connection.load(authUserId); - const inventory = await inventoryPromise; if (disposed || bootstrapGeneration !== generationRef.current) { return; } - setPairedDesktops(inventory); setConnectionConfig(config); + setBootstrapPending(false); if (config?.wsUrl || config?.host) { await connectLive(config).catch(() => undefined); } else if (demoByDefault) { enterDemoMode(); } - })(); + })().catch((error) => { + if (disposed || bootstrapGeneration !== generationRef.current) return; + setBootstrapPending(false); + setConnection({ + status: "error", + presence: "offline", + demoMode: false, + error: toMobileRpcError(error), + }); + }); return () => { disposed = true; selectionIntentRef.current += 1; @@ -305,10 +551,19 @@ export function MobileRemoteProviders({ const value = useMemo( () => ({ + pendingInbox, + focusPermission, + bootstrapPending, connection, sessions, transcriptItems: transcriptView.items, transcriptPhase: transcriptView.phase, + transcriptSessionId: transcript.sessionId, + openedSession, + openingReady: + openingClient === clientRef.current && + (transcript.indexPhase === "ready" || + transcript.indexPhase === "empty"), transcriptError: transcriptView.error, transcriptTruncated: transcriptView.truncated, transcriptRounds: transcript.rounds, @@ -319,10 +574,13 @@ export function MobileRemoteProviders({ activePermission, permissionQueueDepth, permissionSubmitting, - rpc: clientRef.current, + permissionFailed, + rpc, + readStateSync, connectionConfig, pairedDesktops, connectLive, + retryConnection, switchPairedDesktop, enterDemoMode, disconnect, @@ -340,53 +598,70 @@ export function MobileRemoteProviders({ stopSession, sessionModel, refreshSessionModel, + loadSessionModels, setSessionModel, }), [ - activePermission, - connectLive, - connectionConfig, + pendingInbox, + focusPermission, + bootstrapPending, connection, - dismissPermissionHead, - disconnect, - enterDemoMode, + sessions, + transcriptView.items, + transcriptView.phase, + transcriptView.error, + transcriptView.truncated, + transcriptView.roundId, + transcript.sessionId, + transcript.indexPhase, + transcript.rounds, + transcript.roundsComplete, + transcript.selectedRoundId, + openedSession, + openingClient, + clientRef, + sendStatus, + activePermission, permissionQueueDepth, permissionSubmitting, - refreshSessionModel, + permissionFailed, + rpc, + readStateSync, + connectionConfig, + pairedDesktops, + connectLive, + retryConnection, + switchPairedDesktop, + enterDemoMode, + disconnect, refreshSessions, loadMoreSessions, sessionsHasMore, - openSessionFileInDesktop, - pairedDesktops, - respondPermission, - retrySelectedRound, + subscribeSession, + unsubscribeSession, selectRound, + retrySelectedRound, sendMessage, - sendStatus, + openSessionFileInDesktop, + respondPermission, + dismissPermissionHead, + stopSession, sessionModel, - sessions, + refreshSessionModel, + loadSessionModels, setSessionModel, - stopSession, - subscribeSession, - switchPairedDesktop, - transcript.rounds, - transcript.roundsComplete, - transcript.selectedRoundId, - transcriptView.error, - transcriptView.items, - transcriptView.phase, - transcriptView.roundId, - transcriptView.truncated, - unsubscribeSession, - clientRef, ] ); return ( - {children} + + {children} + ); } MobileRemoteProviders.displayName = "MobileRemoteProviders"; + +const logger = createLogger("MobileRemoteProviders"); diff --git a/src/modules/MobileRemote/app/mobileReadStateSync.test.ts b/src/modules/MobileRemote/app/mobileReadStateSync.test.ts new file mode 100644 index 0000000000..291c3b4e93 --- /dev/null +++ b/src/modules/MobileRemote/app/mobileReadStateSync.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + MobileRpcClient, + RpcNotificationHandler, +} from "../connection/mobileRpcClient"; +import { createMobileReadStateSync } from "./mobileReadStateSync"; + +async function flush() { + for (let i = 0; i < 50; i++) await Promise.resolve(); +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function transport() { + const visited = new Set(); + const handlers = new Set(); + const call = vi.fn( + async (method: string, params?: Record) => { + const ids = params?.sessionIds as string[]; + if (method === "session/mark_visited") + ids.forEach((id) => visited.add(id)); + return { visitedIds: ids.filter((id) => visited.has(id)) }; + } + ); + const client = { + call, + onNotification: (fn: RpcNotificationHandler) => { + handlers.add(fn); + return () => { + handlers.delete(fn); + }; + }, + notify: vi.fn(), + close: vi.fn(), + readyState: 1, + } as MobileRpcClient; + return { + client, + call, + visited, + handlers, + changed: () => + handlers.forEach((fn) => fn("session/read_state_changed", {})), + }; +} + +describe("mobile read-state lifecycle", () => { + it("queries only watched IDs and reacts to Desktop push without fetching transcripts or the roster", async () => { + const rpc = transport(); + const sync = createMobileReadStateSync(); + sync.watch(["a", "b"]); + sync.connect(rpc.client, "account/desktop"); + await flush(); + expect([...sync.getSnapshot()]).toEqual([ + ["a", false], + ["b", false], + ]); + rpc.visited.add("a"); + rpc.changed(); + await flush(); + expect(sync.getSnapshot().get("a")).toBe(true); + expect( + rpc.call.mock.calls.every(([method]) => method === "session/read_state") + ).toBe(true); + const count = rpc.call.mock.calls.length; + await flush(); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(count); + sync.dispose(); + expect(rpc.handlers.size).toBe(0); + }); + it("coalesces a burst into at most one trailing query and rejects stale projections", async () => { + const rpc = transport(); + const first = deferred<{ visitedIds: string[] }>(); + rpc.call.mockImplementationOnce(() => first.promise); + const sync = createMobileReadStateSync(); + sync.watch(["a"]); + sync.connect(rpc.client, "desktop"); + await flush(); + rpc.visited.add("a"); + for (let i = 0; i < 100; i++) rpc.changed(); + expect(rpc.call).toHaveBeenCalledTimes(1); + first.resolve({ visitedIds: [] }); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(2); + expect(sync.getSnapshot().get("a")).toBe(true); + sync.dispose(); + }); + it("retains uncertain receipts on the same desktop but drops them when switching accounts/desktops", async () => { + const a = transport(), + b = transport(); + const lost = deferred<{ visitedIds: string[] }>(); + a.call.mockImplementationOnce(() => lost.promise); + const sync = createMobileReadStateSync(); + sync.connect(a.client, "a"); + sync.markVisited("s"); + await flush(); + sync.connect(null, "a"); + sync.connect(b.client, "a"); + await flush(); + expect(b.visited.has("s")).toBe(true); + lost.resolve({ visitedIds: ["s"] }); + await flush(); + sync.connect(null, "a"); + sync.markVisited("never-send-to-b"); + sync.connect(a.client, "b"); + await flush(); + expect(a.visited.has("never-send-to-b")).toBe(false); + sync.dispose(); + }); + it("never treats a missing mark acknowledgment as success and retries only on an external trigger", async () => { + const rpc = transport(); + rpc.call.mockResolvedValueOnce({ visitedIds: [] }); + const sync = createMobileReadStateSync(); + sync.connect(rpc.client, "a"); + sync.markVisited("s"); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(1); + expect(sync.getSnapshot().size).toBe(0); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(1); + sync.refresh(); + await flush(); + expect(rpc.visited.has("s")).toBe(true); + sync.dispose(); + }); + it("discards old replies after scope switches, hidden transport, and disposal", async () => { + const rpc = transport(); + const first = deferred<{ visitedIds: string[] }>(); + rpc.call.mockImplementationOnce(() => first.promise); + const sync = createMobileReadStateSync(); + sync.watch(["a"]); + sync.connect(rpc.client, "old"); + await flush(); + sync.connect(null, "old"); + first.resolve({ visitedIds: ["a"] }); + await flush(); + expect(sync.getSnapshot().size).toBe(0); + expect(rpc.handlers.size).toBe(0); + sync.dispose(); + }); + it("bounds both watched IDs and each wire batch; same watch is a no-op", async () => { + const rpc = transport(); + const sync = createMobileReadStateSync(); + const ids = Array.from({ length: 1300 }, (_, i) => `s-${i}`); + sync.watch(ids); + sync.connect(rpc.client, "a"); + await flush(); + expect(sync.getSnapshot().size).toBe(1200); + expect(rpc.call).toHaveBeenCalledTimes(6); + expect( + rpc.call.mock.calls.every( + ([, params]) => (params?.sessionIds as string[]).length <= 200 + ) + ).toBe(true); + sync.watch(ids); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(6); + sync.watch([]); + await flush(); + expect(sync.getSnapshot().size).toBe(0); + rpc.changed(); + await flush(); + expect(rpc.call).toHaveBeenCalledTimes(6); + sync.dispose(); + }); + it("rejects foreign IDs in a reply instead of accepting them into the projection", async () => { + const rpc = transport(); + rpc.call.mockResolvedValueOnce({ visitedIds: ["foreign"] }); + const sync = createMobileReadStateSync(); + sync.watch(["a"]); + sync.connect(rpc.client, "a"); + await flush(); + expect(sync.getSnapshot().size).toBe(0); + sync.dispose(); + }); +}); diff --git a/src/modules/MobileRemote/app/mobileReadStateSync.ts b/src/modules/MobileRemote/app/mobileReadStateSync.ts new file mode 100644 index 0000000000..f846141c76 --- /dev/null +++ b/src/modules/MobileRemote/app/mobileReadStateSync.ts @@ -0,0 +1,173 @@ +import { createLogger } from "@src/hooks/logger"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; + +const BATCH = 200; +const MAX_WATCHED = 1200; +const EMPTY: ReadonlyMap = new Map(); + +function visitedReply( + value: unknown, + requested: readonly string[] +): Set { + const ids = (value as { visitedIds?: unknown } | null)?.visitedIds; + if ( + !Array.isArray(ids) || + ids.length > requested.length || + ids.some((id) => typeof id !== "string" || !requested.includes(id)) + ) { + throw new Error("Invalid read-state response"); + } + return new Set(ids); +} + +/** Provider-lifetime, connection-scoped projection. No polling, persistent + * phone truth, or transcript fetches. One RPC in flight; bursts coalesce. */ +export function createMobileReadStateSync() { + let client: MobileRpcClient | null = null; + let scope: string | null = null; + let generation = 0; + let revision = 0; + let watched: string[] = []; + const pending = new Set(); + let snapshot = EMPTY; + let dirty = false; + let running: number | null = null; + let unlisten: (() => void) | undefined; + const listeners = new Set<() => void>(); + const emit = (next: ReadonlyMap) => { + if ( + next.size === snapshot.size && + [...next].every(([id, value]) => snapshot.get(id) === value) + ) + return; + snapshot = next; + listeners.forEach((fn) => fn()); + }; + const pump = () => { + if (!client || running === generation || (!dirty && pending.size === 0)) + return; + const transport = client; + const token = generation; + const current = () => token === generation && client === transport; + running = token; + void Promise.resolve() + .then(async () => { + try { + while (current() && (dirty || pending.size > 0)) { + dirty = false; + const marks = [...pending].slice(0, BATCH); + if (marks.length) { + const reply = visitedReply( + await transport.call("session/mark_visited", { + sessionIds: marks, + }), + marks + ); + if (!current()) return; + if (marks.some((id) => !reply.has(id))) + throw new Error("Desktop did not acknowledge the read receipt"); + marks.forEach((id) => pending.delete(id)); + } + const requestRevision = revision; + const ids = watched; + const next = new Map(); + for (let offset = 0; offset < ids.length; offset += BATCH) { + const batch = ids.slice(offset, offset + BATCH); + const reply = visitedReply( + await transport.call("session/read_state", { + sessionIds: batch, + }), + batch + ); + if (!current()) return; + if (requestRevision !== revision) break; + batch.forEach((id) => next.set(id, reply.has(id))); + } + if (!current()) return; + if (requestRevision === revision) emit(next); + } + } catch { + // Unknown is not unread. Retain pending receipts for the next explicit + // activation, invalidation, or reconnect; never spin on failure. + if (current()) emit(EMPTY); + } finally { + if (running === token) running = null; + } + }) + .catch((error) => logger.warn("Background operation failed", error)); + }; + const refresh = () => { + revision++; + dirty = true; + pump(); + }; + return { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + connect(next: MobileRpcClient | null, nextScope: string) { + if (next === client && scope === nextScope) return; + unlisten?.(); + unlisten = undefined; + generation++; + if (scope !== nextScope) { + pending.clear(); + // Never send the previous account/desktop's visible IDs to its successor. + if (scope !== null) watched = []; + } + scope = nextScope; + client = next; + emit(EMPTY); + unlisten = next?.onNotification((method) => { + if (method === "session/read_state_changed") refresh(); + }); + refresh(); + }, + watch(ids: readonly string[]) { + const next = [...new Set(ids)] + .filter((id) => id.length > 0 && id.length <= 1024) + .slice(0, MAX_WATCHED); + if ( + next.length === watched.length && + next.every((id, index) => watched[index] === id) + ) + return; + watched = next; + // Do not show state for the previously visible list while querying. + emit(EMPTY); + refresh(); + }, + markVisited(id: string) { + if ( + !id || + id.length > 1024 || + pending.has(id) || + snapshot.get(id) === true + ) + return; + // Bounded navigation intent queue, retained only within this Desktop scope. + if (pending.size >= BATCH) return; + pending.add(id); + refresh(); + }, + refresh, + dispose() { + generation++; + client = null; + unlisten?.(); + unlisten = undefined; + pending.clear(); + watched = []; + emit(EMPTY); + }, + }; +} + +export type MobileReadStateSync = ReturnType; + +const logger = createLogger("mobileReadStateSync"); diff --git a/src/modules/MobileRemote/app/useMobileConnectionActions.ts b/src/modules/MobileRemote/app/useMobileConnectionActions.ts deleted file mode 100644 index f80f24f34b..0000000000 --- a/src/modules/MobileRemote/app/useMobileConnectionActions.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { type Dispatch, type SetStateAction, useCallback } from "react"; - -import { toMobileRpcError } from "../connection/mobileRpcClient"; -import { MobileConnectionAuthorizationError } from "../connection/types"; -import type { - MobileConnectionConfig, - MobileConnectionState, -} from "../connection/types"; -import type { MobileRemotePlatform } from "../platform/types"; -import type { useMobileConnectionEstablish } from "./useMobileConnectionEstablish"; -import type { MobileConnectionRefs } from "./useMobileConnectionRefs"; -import type { - RemoteReconnectController, - useMobileConnectionTransport, -} from "./useMobileConnectionTransport"; -import type { useMobilePermissions } from "./useMobilePermissions"; -import type { useMobileSend } from "./useMobileSend"; -import type { useMobileSessionList } from "./useMobileSessionList"; -import type { useMobileTranscript } from "./useMobileTranscript"; - -type TransportApi = ReturnType; - -interface UseMobileConnectionActionsParams { - platform: MobileRemotePlatform; - authUserId: string; - refs: Pick< - MobileConnectionRefs, - | "selectionIntentRef" - | "generationRef" - | "activeConfigRef" - | "activeSessionRef" - >; - reconnect: RemoteReconnectController; - setConnection: Dispatch>; - setConnectionConfig: Dispatch>; - clearReconnectTimer: TransportApi["clearReconnectTimer"]; - releaseTransport: TransportApi["releaseTransport"]; - persistConnection: TransportApi["persistConnection"]; - establishConnection: ReturnType< - typeof useMobileConnectionEstablish - >["establishConnection"]; - resetSessions: ReturnType["resetSessions"]; - resetPermissions: ReturnType["resetPermissions"]; - resetSend: ReturnType["resetSend"]; - resetTranscript: ReturnType["resetTranscript"]; -} - -/** User-initiated connection transitions: connect, disconnect, switch desktop. */ -export function useMobileConnectionActions({ - platform, - authUserId, - refs, - reconnect, - setConnection, - setConnectionConfig, - clearReconnectTimer, - releaseTransport, - persistConnection, - establishConnection, - resetSessions, - resetPermissions, - resetSend, - resetTranscript, -}: UseMobileConnectionActionsParams) { - const { - selectionIntentRef, - generationRef, - activeConfigRef, - activeSessionRef, - } = refs; - - const connectLive = useCallback( - async (config: MobileConnectionConfig) => { - selectionIntentRef.current += 1; - generationRef.current += 1; - const generation = generationRef.current; - clearReconnectTimer(); - releaseTransport(true); - reconnect.reset(); - resetSessions(); - resetPermissions(); - resetSend(); - activeConfigRef.current = config; - setConnectionConfig(config); - await persistConnection(config); - if (generation !== generationRef.current) { - throw new Error("Connection was superseded"); - } - setConnection((prev) => ({ - ...prev, - status: "connecting", - presence: "unknown", - demoMode: false, - error: undefined, - })); - try { - await establishConnection(config, generation); - } catch (error) { - if (generation === generationRef.current) { - if (error instanceof MobileConnectionAuthorizationError) - activeConfigRef.current = null; - setConnection({ - status: "error", - presence: "offline", - demoMode: false, - error: toMobileRpcError(error), - }); - } - throw error; - } - }, - [ - clearReconnectTimer, - reconnect, - establishConnection, - persistConnection, - releaseTransport, - resetSessions, - resetPermissions, - resetSend, - activeConfigRef, - generationRef, - selectionIntentRef, - setConnection, - setConnectionConfig, - ] - ); - - const disconnect = useCallback(async () => { - selectionIntentRef.current += 1; - generationRef.current += 1; - clearReconnectTimer(); - activeConfigRef.current = null; - setConnectionConfig(null); - releaseTransport(true); - activeSessionRef.current = null; - setConnection({ - status: "disconnected", - presence: "unknown", - demoMode: false, - }); - resetSessions(); - resetTranscript(); - resetSend(); - resetPermissions(); - await persistConnection(null); - }, [ - resetTranscript, - clearReconnectTimer, - persistConnection, - releaseTransport, - resetSessions, - resetPermissions, - resetSend, - activeConfigRef, - activeSessionRef, - generationRef, - selectionIntentRef, - setConnection, - setConnectionConfig, - ]); - - const switchPairedDesktop = useCallback( - async (desktopId: string) => { - const selectionIntent = ++selectionIntentRef.current; - const config = await platform.connection.selectPairedDesktop( - authUserId, - desktopId - ); - if (selectionIntent !== selectionIntentRef.current) return; - if (!config) throw new Error("Paired desktop is unavailable"); - await connectLive(config); - }, - [authUserId, connectLive, platform.connection, selectionIntentRef] - ); - - return { connectLive, disconnect, switchPairedDesktop }; -} diff --git a/src/modules/MobileRemote/app/useMobileConnectionEstablish.ts b/src/modules/MobileRemote/app/useMobileConnectionEstablish.ts index 7e012c203e..6235d9c08c 100644 --- a/src/modules/MobileRemote/app/useMobileConnectionEstablish.ts +++ b/src/modules/MobileRemote/app/useMobileConnectionEstablish.ts @@ -1,11 +1,11 @@ -import { - type Dispatch, - type RefObject, - type SetStateAction, - useCallback, -} from "react"; +import { useCallback } from "react"; -import type { MobileAuthContextValue } from "../auth/MobileAuthContext"; +import { mobileComposerDesktopScope } from "../components/composer/mobileComposerDraftStore"; +import { + desktopIdentityName, + pairedDesktopId, + withInitializedDesktop, +} from "../connection/mobileDesktopIdentity"; import { createMobileRpcClient, toMobileRpcError, @@ -17,66 +17,50 @@ import type { MobileConnectionConfig, MobileConnectionState, } from "../connection/types"; -import { DEMO_DESKTOP_NAME } from "../demo/demoFixtures"; -import type { MobileRemotePlatform } from "../platform/types"; import { waitForPairingApproval, waitForSocketOpen, } from "./mobileSocketHandshake"; -import type { MobileConnectionRefs } from "./useMobileConnectionRefs"; -import type { - RemoteReconnectController, - useMobileConnectionTransport, -} from "./useMobileConnectionTransport"; -import type { useMobileRpcNotifications } from "./useMobileRpcNotifications"; -import type { useMobileSessionList } from "./useMobileSessionList"; -import type { useMobileTranscript } from "./useMobileTranscript"; - -type TranscriptApi = ReturnType; +import type { MobileRemoteState } from "./useMobileRemoteState"; -interface UseMobileConnectionEstablishParams { - platform: MobileRemotePlatform; - authUserId: string; - authRef: RefObject; - refs: MobileConnectionRefs; - reconnect: RemoteReconnectController; - setConnection: Dispatch>; - releaseTransport: ReturnType< - typeof useMobileConnectionTransport - >["releaseTransport"]; - handleRpcNotification: ReturnType; - requestSessionList: ReturnType< - typeof useMobileSessionList - >["requestSessionList"]; - requestSessionSnapshot: TranscriptApi["requestSessionSnapshot"]; - beginLoad: TranscriptApi["beginLoad"]; +export class SupersededConnectionError extends Error { + constructor() { + super("Connection was superseded"); + } } -/** Opens, authenticates, and re-opens the desktop socket for one generation. */ -export function useMobileConnectionEstablish({ - platform, - authUserId, - authRef, - refs, - reconnect, - setConnection, - releaseTransport, - handleRpcNotification, - requestSessionList, - requestSessionSnapshot, - beginLoad, -}: UseMobileConnectionEstablishParams) { +export function useMobileConnectionEstablish( + state: MobileRemoteState, + authUserId: string, + persistConnection: (config: MobileConnectionConfig | null) => Promise, + releaseTransport: (close: boolean) => void, + handleRpcNotification: ( + method: string, + params: Record | undefined + ) => void +) { const { + platform, + draftStore, + authRef, preparationRef, clientRef, + setRpc, socketRef, activeSessionRef, unsubscribeRpcRef, activeConfigRef, generationRef, + reconnect, scheduleReconnectRef, - } = refs; - + setConnection, + setConnectionConfig, + setPairedDesktops, + connectionRef, + requestSessionList, + beginLoad, + requestSessionSnapshot, + } = state; const establishConnection = useCallback( async (config: MobileConnectionConfig, generation: number) => { const deviceLabel = @@ -103,7 +87,13 @@ export function useMobileConnectionEstablish({ ); preparation.signal.throwIfAborted(); if (generation !== generationRef.current || platform.runtime.isHidden()) - throw new Error("Connection was superseded"); + throw new SupersededConnectionError(); + } catch (error) { + if (preparation.signal.aborted) throw new SupersededConnectionError(); + if (error instanceof MobileConnectionAuthorizationError) { + draftStore.clearDesktop(mobileComposerDesktopScope(config, {})); + } + throw error; } finally { if (preparationRef.current === preparation) preparationRef.current = null; @@ -111,6 +101,17 @@ export function useMobileConnectionEstablish({ const socket = platform.connection.createSocket(preparedUrl); let authenticated = false; let intentionalClose = false; + let authorizationDenied = false; + let pairedConfig = config; + // Policy closes during initialize are just as terminal as post-handshake + // revocation. The RPC client's generic close error must not trigger retries. + socket.addEventListener( + "close", + (event) => { + authorizationDenied = event.code === 1008; + }, + { once: true } + ); socketRef.current = socket; try { @@ -118,16 +119,29 @@ export function useMobileConnectionEstablish({ if (generation !== generationRef.current) { intentionalClose = true; socket.close(); - throw new Error("Connection was superseded"); + throw new SupersededConnectionError(); } const client = createMobileRpcClient(socket, platform.runtime); clientRef.current = client; + setRpc(client); unsubscribeRpcRef.current = client.onNotification( handleRpcNotification ); if (config.pairingCode) { await waitForPairingApproval(socket, client, platform.runtime); + if ( + generation !== generationRef.current || + socketRef.current !== socket + ) + throw new SupersededConnectionError(); + // Relay approval, not Desktop availability, finalizes pairing. Keep + // the durable device credential, but never replay the one-time code. + const { pairingCode: _approvedCode, ...confirmed } = config; + pairedConfig = confirmed; + activeConfigRef.current = confirmed; + setConnectionConfig(confirmed); + void persistConnection(confirmed).catch(() => undefined); } const init = await client.call("initialize", { @@ -139,25 +153,60 @@ export function useMobileConnectionEstablish({ capabilities: { interactions: ["permission"], streaming: true }, deviceLabel, }); - if (generation !== generationRef.current) { + if ( + generation !== generationRef.current || + socketRef.current !== socket + ) { intentionalClose = true; client.close(); - throw new Error("Connection was superseded"); + throw new SupersededConnectionError(); } authenticated = true; reconnect.reset(); - setConnection({ + const identifiedConfig = withInitializedDesktop(pairedConfig, init); + const desktopName = desktopIdentityName( + identifiedConfig.desktopIdentity + ); + if (identifiedConfig !== config) { + activeConfigRef.current = identifiedConfig; + setConnectionConfig(identifiedConfig); + // Publish verified metadata immediately; secure-storage latency must not + // delay sessions or display an opaque ID after initialize succeeds. + setPairedDesktops((desktops) => { + const id = pairedDesktopId(config); + const existing = desktops.find((desktop) => desktop.id === id); + return [ + { + id, + name: desktopName ?? existing?.name ?? id, + active: true, + updatedAtMs: existing?.updatedAtMs ?? platform.runtime.now(), + desktopIdentity: identifiedConfig.desktopIdentity, + }, + ...desktops + .filter((desktop) => desktop.id !== id) + .map((desktop) => ({ ...desktop, active: false })), + ].slice(0, 20); + }); + } + // Retry the confirmed credential write even without desktop metadata: + // an earlier approval save may have failed before Desktop came online. + void persistConnection(identifiedConfig).catch(() => undefined); + const connected: MobileConnectionState = { status: "connected", presence: "online", desktopId: init.desktopId ?? config.desktopId, - desktopName: init.desktopName ?? DEMO_DESKTOP_NAME, + desktopName: desktopName ?? config.desktopId ?? config.host, // Authorization is server-owned. An older/incomplete initialize // response must never silently upgrade the phone to write access. tier: init.tier ?? "read_only", capabilities: init.capabilities, demoMode: false, - }); + }; + // Restoration runs before React commits; use this handshake's capabilities. + connectionRef.current = connected; + setConnection(connected); socket.addEventListener( "close", (event) => { @@ -171,6 +220,11 @@ export function useMobileConnectionEstablish({ } releaseTransport(false); if (event.code === 1008) { + draftStore.clearDesktop( + mobileComposerDesktopScope(identifiedConfig, { + desktopId: identifiedConfig.desktopId, + }) + ); activeConfigRef.current = null; setConnection((prev) => ({ ...prev, @@ -190,90 +244,76 @@ export function useMobileConnectionEstablish({ presence: "offline", error: undefined, })); - scheduleReconnectRef.current(config, generation); + scheduleReconnectRef.current(identifiedConfig, generation); }, { once: true } ); - await requestSessionList(client); - if ( - socketRef.current !== socket || - generation !== generationRef.current - ) - return; - if (activeSessionRef.current) { + // The roster is independent of the selected conversation. A slow list + // must not serialize reconnect → latest body behind unrelated sessions. + const restoreActiveSession = async () => { + if ( + socketRef.current !== socket || + generation !== generationRef.current + ) + return; const sessionId = activeSessionRef.current; + if (!sessionId) return; const subscriptionGeneration = beginLoad(sessionId); await requestSessionSnapshot( client, sessionId, subscriptionGeneration ).catch(() => undefined); - } + }; + await Promise.all([restoreActiveSession(), requestSessionList(client)]); } catch (error) { intentionalClose = true; - if (socketRef.current === socket) releaseTransport(true); + if (socketRef.current !== socket) throw new SupersededConnectionError(); + releaseTransport(true); + if ( + authorizationDenied || + error instanceof MobileConnectionAuthorizationError + ) { + draftStore.clearDesktop(mobileComposerDesktopScope(config, {})); + } + if (authorizationDenied) + throw new MobileConnectionAuthorizationError( + "Device access was revoked or pairing expired" + ); throw error; } }, [ - authUserId, - reconnect, - handleRpcNotification, - platform.clientInfo, + platform.clientInfo.defaultDeviceLabel, + platform.clientInfo.name, + platform.clientInfo.version, platform.connection, platform.runtime, - releaseTransport, - requestSessionList, - requestSessionSnapshot, - beginLoad, - activeConfigRef, - activeSessionRef, - authRef, - clientRef, - generationRef, preparationRef, - scheduleReconnectRef, - setConnection, socketRef, + authUserId, + generationRef, + authRef, + draftStore, + clientRef, + setRpc, unsubscribeRpcRef, - ] - ); - - const runReconnect = useCallback( - async (config: MobileConnectionConfig, generation: number) => { - if (generation !== generationRef.current || platform.runtime.isHidden()) { - return; - } - setConnection((prev) => ({ - ...prev, - status: "connecting", - presence: "offline", - error: undefined, - })); - try { - await establishConnection(config, generation); - } catch (error) { - if (generation !== generationRef.current) return; - const denied = error instanceof MobileConnectionAuthorizationError; - if (denied) activeConfigRef.current = null; - setConnection((prev) => ({ - ...prev, - status: denied ? "error" : "connecting", - presence: "offline", - error: toMobileRpcError(error), - })); - if (!denied) scheduleReconnectRef.current(config, generation); - } - }, - [ - establishConnection, - platform.runtime, + handleRpcNotification, + reconnect, + persistConnection, + connectionRef, + setConnection, + requestSessionList, activeConfigRef, - generationRef, + setConnectionConfig, + setPairedDesktops, + releaseTransport, scheduleReconnectRef, - setConnection, + activeSessionRef, + beginLoad, + requestSessionSnapshot, ] ); - return { establishConnection, runReconnect }; + return establishConnection; } diff --git a/src/modules/MobileRemote/app/useMobileConnectionRefs.ts b/src/modules/MobileRemote/app/useMobileConnectionRefs.ts deleted file mode 100644 index c67b15cc9f..0000000000 --- a/src/modules/MobileRemote/app/useMobileConnectionRefs.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useRef } from "react"; - -import type { MobileRpcClient } from "../connection/mobileRpcClient"; -import type { MobileConnectionConfig } from "../connection/types"; - -export type MobileConnectionRefs = ReturnType; - -/** Mutable transport identity shared by the provider's connection hooks. */ -export function useMobileConnectionRefs() { - const preparationRef = useRef(null); - const clientRef = useRef(null); - const socketRef = useRef(null); - const activeSessionRef = useRef(null); - const unsubscribeRpcRef = useRef<(() => void) | null>(null); - const activeConfigRef = useRef(null); - const generationRef = useRef(0); - const selectionIntentRef = useRef(0); - const recoverRef = useRef< - (config: MobileConnectionConfig, generation: number) => Promise - >(async () => undefined); - const connectionWriteChainRef = useRef>(Promise.resolve()); - const scheduleReconnectRef = useRef< - (config: MobileConnectionConfig, generation: number) => void - >(() => undefined); - - return { - preparationRef, - clientRef, - socketRef, - activeSessionRef, - unsubscribeRpcRef, - activeConfigRef, - generationRef, - selectionIntentRef, - recoverRef, - connectionWriteChainRef, - scheduleReconnectRef, - }; -} diff --git a/src/modules/MobileRemote/app/useMobileConnectionTransport.ts b/src/modules/MobileRemote/app/useMobileConnectionTransport.ts deleted file mode 100644 index 201c8e1d48..0000000000 --- a/src/modules/MobileRemote/app/useMobileConnectionTransport.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - type Dispatch, - type SetStateAction, - useCallback, - useEffect, -} from "react"; - -import type { createRemoteReconnectController } from "../connection/remoteReconnectController"; -import type { - MobileConnectionConfig, - MobileConnectionState, - MobilePairedDesktopSummary, -} from "../connection/types"; -import { DEMO_DESKTOP_NAME, DEMO_SESSIONS } from "../demo/demoFixtures"; -import type { MobileRemotePlatform } from "../platform/types"; -import type { MobileConnectionRefs } from "./useMobileConnectionRefs"; -import type { useMobilePermissions } from "./useMobilePermissions"; -import type { useMobileSend } from "./useMobileSend"; -import type { useMobileSessionList } from "./useMobileSessionList"; -import type { useMobileTranscript } from "./useMobileTranscript"; - -type TranscriptApi = ReturnType; -type SessionListApi = ReturnType; -export type RemoteReconnectController = ReturnType< - typeof createRemoteReconnectController ->; - -interface UseMobileConnectionTransportParams { - platform: MobileRemotePlatform; - authUserId: string; - refs: MobileConnectionRefs; - reconnect: RemoteReconnectController; - setConnection: Dispatch>; - setConnectionConfig: Dispatch>; - setPairedDesktops: Dispatch>; - invalidateTranscriptRequests: TranscriptApi["invalidateTranscriptRequests"]; - showDemoTranscript: TranscriptApi["showDemoTranscript"]; - resetSessions: SessionListApi["resetSessions"]; - resetSend: ReturnType["resetSend"]; - resetPermissions: ReturnType["resetPermissions"]; -} - -/** Socket lifecycle: persist config, release transport, demo mode, visibility. */ -export function useMobileConnectionTransport({ - platform, - authUserId, - refs, - reconnect, - setConnection, - setConnectionConfig, - setPairedDesktops, - invalidateTranscriptRequests, - showDemoTranscript, - resetSessions, - resetSend, - resetPermissions, -}: UseMobileConnectionTransportParams) { - const { - preparationRef, - clientRef, - socketRef, - activeSessionRef, - unsubscribeRpcRef, - activeConfigRef, - generationRef, - selectionIntentRef, - connectionWriteChainRef, - } = refs; - - const persistConnection = useCallback( - (config: MobileConnectionConfig | null) => { - const operation = connectionWriteChainRef.current.then(async () => { - await platform.connection.save(authUserId, config); - setPairedDesktops( - await platform.connection.listPairedDesktops(authUserId) - ); - }); - connectionWriteChainRef.current = operation.catch(() => undefined); - return operation; - }, - [ - authUserId, - connectionWriteChainRef, - platform.connection, - setPairedDesktops, - ] - ); - - const clearReconnectTimer = useCallback(() => reconnect.clear(), [reconnect]); - - const releaseTransport = useCallback( - (close: boolean) => { - reconnect.invalidate(); - preparationRef.current?.abort(); - preparationRef.current = null; - // Invalidate every in-flight subscribe/refresh from the old socket before - // it can reject or resolve into the retained logical session state. - invalidateTranscriptRequests(); - unsubscribeRpcRef.current?.(); - unsubscribeRpcRef.current = null; - const client = clientRef.current; - clientRef.current = null; - const socket = socketRef.current; - socketRef.current = null; - if (close) { - if (client) client.close(); - else socket?.close(); - } - }, - [ - reconnect, - invalidateTranscriptRequests, - clientRef, - preparationRef, - socketRef, - unsubscribeRpcRef, - ] - ); - - const enterDemoMode = useCallback(() => { - selectionIntentRef.current += 1; - generationRef.current += 1; - clearReconnectTimer(); - activeConfigRef.current = null; - setConnectionConfig(null); - releaseTransport(true); - setConnection({ - status: "connected", - presence: "online", - desktopName: DEMO_DESKTOP_NAME, - tier: "full", - capabilities: { roundHistory: true, openSessionFile: false }, - demoMode: true, - }); - resetSessions(DEMO_SESSIONS); - showDemoTranscript(activeSessionRef.current ?? "demo"); - resetSend(); - resetPermissions(); - }, [ - showDemoTranscript, - clearReconnectTimer, - releaseTransport, - resetSessions, - resetPermissions, - resetSend, - activeConfigRef, - activeSessionRef, - generationRef, - selectionIntentRef, - setConnection, - setConnectionConfig, - ]); - - useEffect(() => { - const handleVisible = () => { - const config = activeConfigRef.current; - clearReconnectTimer(); - if (platform.runtime.isHidden()) { - preparationRef.current?.abort(); - if (clientRef.current || socketRef.current) { - releaseTransport(true); - setConnection((previous) => ({ - ...previous, - status: "connecting", - presence: "offline", - error: undefined, - })); - } - return; - } - if (config && !clientRef.current) { - void reconnect - .run(config, generationRef.current) - .catch(() => undefined); - } - }; - return platform.runtime.subscribeVisibility(handleVisible); - }, [ - clearReconnectTimer, - platform.runtime, - releaseTransport, - reconnect, - activeConfigRef, - clientRef, - generationRef, - preparationRef, - setConnection, - socketRef, - ]); - - return { - persistConnection, - clearReconnectTimer, - releaseTransport, - enterDemoMode, - }; -} diff --git a/src/modules/MobileRemote/app/useMobilePendingInbox.test.ts b/src/modules/MobileRemote/app/useMobilePendingInbox.test.ts new file mode 100644 index 0000000000..df214fbec9 --- /dev/null +++ b/src/modules/MobileRemote/app/useMobilePendingInbox.test.ts @@ -0,0 +1,287 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { + MobileRpcClient, + RpcNotificationHandler, +} from "../connection/mobileRpcClient"; +import type { MobilePendingPermission } from "../connection/types"; +import type { MobileRemoteRuntimePort } from "../platform/types"; +import { useMobilePendingInbox } from "./useMobilePendingInbox"; + +function deferred() { + let resolve!: (value: unknown) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +const item = (requestId: string): MobilePendingPermission => ({ + kind: "permission", + origin: "rust_agent", + sessionId: `session-${requestId}`, + requestId, + toolName: "shell", + toolArgs: { command: "pwd" }, + createdAtMs: 1000, +}); +const snapshot = (id: string, complete = true) => ({ + interactions: [item(id)], + complete, +}); + +describe("useMobilePendingInbox", () => { + let root: ReturnType; + let current: ReturnType; + let hidden: boolean; + let visibility: Set<() => void>; + let notifications: Set; + let runtime: MobileRemoteRuntimePort; + let onSnapshot: Mock<(items: MobilePendingPermission[]) => void>; + let props: Parameters[0]; + function Harness() { + const value = useMobilePendingInbox(props); + React.useLayoutEffect(() => { + current = value; + }); + return null; + } + async function render() { + await act(async () => root.render(React.createElement(Harness))); + } + function client(call: ReturnType) { + return { + call, + onNotification: (handler: RpcNotificationHandler) => { + notifications.add(handler); + return () => notifications.delete(handler); + }, + } as unknown as MobileRpcClient; + } + async function push() { + await act(async () => { + for (const notify of notifications) + notify("interaction/pending_changed", {}); + }); + } + async function show(isHidden: boolean) { + await act(async () => { + hidden = isHidden; + for (const listener of visibility) listener(); + }); + } + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + hidden = false; + visibility = new Set(); + notifications = new Set(); + onSnapshot = vi.fn<(items: MobilePendingPermission[]) => void>(); + runtime = { + now: () => Date.now(), + random: () => Math.random(), + randomUUID: () => crypto.randomUUID(), + setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs), + clearTimeout: (timeoutId) => window.clearTimeout(timeoutId), + portalContainer: () => document.body, + isHidden: () => hidden, + subscribeVisibility: (listener: () => void) => { + visibility.add(listener); + return () => visibility.delete(listener); + }, + }; + props = { + client: null, + scope: "desktop-a", + online: true, + supported: true, + runtime, + onSnapshot, + }; + }); + afterEach(async () => { + await act(async () => root.unmount()); + expect(visibility.size).toBe(0); + expect(notifications.size).toBe(0); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("hydrates the global snapshot, then removes desktop-resolved requests on push", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(snapshot("pending")) + .mockResolvedValueOnce({ interactions: [], complete: true }); + props.client = client(call); + await render(); + expect(current.items).toEqual([item("pending")]); + expect(call).toHaveBeenCalledWith("interaction/pending_all", {}); + await push(); + expect(current).toMatchObject({ + phase: "ready", + complete: true, + items: [], + }); + expect(onSnapshot).toHaveBeenLastCalledWith([]); + }); + + it("coalesces notification bursts during an in-flight read and rejects its stale snapshot", async () => { + const old = deferred(); + const latest = deferred(); + const call = vi + .fn() + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(latest.promise); + props.client = client(call); + await render(); + await push(); + await push(); + await push(); + expect(call).toHaveBeenCalledTimes(1); + await act(async () => old.resolve(snapshot("old"))); + expect(call).toHaveBeenCalledTimes(2); + expect(onSnapshot).not.toHaveBeenCalled(); + await act(async () => latest.resolve(snapshot("new"))); + expect(current.items).toEqual([item("new")]); + expect(onSnapshot).toHaveBeenCalledTimes(1); + }); + + it("does no reads while hidden and revalidates once on visibility return", async () => { + hidden = true; + const call = vi.fn().mockResolvedValue(snapshot("visible")); + props.client = client(call); + await render(); + await push(); + await push(); + expect(call).not.toHaveBeenCalled(); + await show(false); + expect(call).toHaveBeenCalledTimes(1); + await show(true); + await push(); + expect(call).toHaveBeenCalledTimes(1); + await show(false); + expect(call).toHaveBeenCalledTimes(2); + }); + + it("retains the known same-desktop snapshot offline without new calls", async () => { + const call = vi.fn().mockResolvedValue(snapshot("known")); + props.client = client(call); + await render(); + props = { ...props, online: false }; + await render(); + await push(); + expect(current.items).toEqual([item("known")]); + expect(call).toHaveBeenCalledTimes(1); + }); + + it("clears data across scope switch and rejects the old client's completion", async () => { + const old = deferred(); + const next = deferred(); + props.client = client(vi.fn().mockReturnValue(old.promise)); + await render(); + props = { + ...props, + scope: "desktop-b", + client: client(vi.fn().mockReturnValue(next.promise)), + }; + await render(); + expect(current.items).toEqual([]); + await act(async () => old.resolve(snapshot("old"))); + expect(current.items).toEqual([]); + expect(onSnapshot).not.toHaveBeenCalled(); + await act(async () => next.resolve(snapshot("new"))); + expect(current.items).toEqual([item("new")]); + expect(notifications.size).toBe(1); + expect(visibility.size).toBe(1); + }); + + it("marks partial snapshots incomplete without authoritatively reconciling missing prompts", async () => { + props.client = client( + vi.fn().mockResolvedValue(snapshot("partial", false)) + ); + await render(); + expect(current).toMatchObject({ + phase: "ready", + complete: false, + items: [item("partial")], + }); + expect(onSnapshot).not.toHaveBeenCalled(); + }); + + it("retains known rows on failure and permits explicit retry", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(snapshot("known")) + .mockRejectedValueOnce(new Error("network")) + .mockResolvedValueOnce(snapshot("retried")); + props.client = client(call); + await render(); + await push(); + expect(current).toMatchObject({ phase: "error", items: [item("known")] }); + await act(async () => current.refresh()); + expect(current).toMatchObject({ phase: "ready", items: [item("retried")] }); + }); + + it("does not request the RPC on unsupported Desktop versions", async () => { + const call = vi.fn(); + props = { ...props, supported: false, client: client(call) }; + await render(); + await push(); + await act(async () => current.refresh()); + expect(call).not.toHaveBeenCalled(); + expect(current.phase).toBe("unsupported"); + }); + + it("unmount disposes listeners and prevents a late snapshot from reconciling", async () => { + const late = deferred(); + props.client = client(vi.fn().mockReturnValue(late.promise)); + await render(); + await act(async () => root.unmount()); + root = createRoot(document.createElement("div")); + await act(async () => late.resolve(snapshot("late"))); + expect(onSnapshot).not.toHaveBeenCalled(); + expect(notifications.size).toBe(0); + expect(visibility.size).toBe(0); + }); + + it.each([ + null, + { interactions: [null], complete: true }, + { interactions: [{ ...item("bad"), toolArgs: null }], complete: true }, + { + interactions: [{ ...item("bad"), origin: ["rust_agent"] }], + complete: true, + }, + { interactions: [{ ...item("bad"), createdAtMs: 1e20 }], complete: true }, + { interactions: [item("bad")], complete: "true" }, + { + interactions: Array.from({ length: 2001 }, (_, id) => item(String(id))), + complete: true, + }, + ])( + "rejects malformed pending payload before global reconciliation (%#)", + async (payload) => { + props.client = client(vi.fn().mockResolvedValue(payload)); + await render(); + expect(current).toMatchObject({ + phase: "error", + items: [], + complete: false, + }); + expect(onSnapshot).not.toHaveBeenCalled(); + } + ); +}); diff --git a/src/modules/MobileRemote/app/useMobilePendingInbox.ts b/src/modules/MobileRemote/app/useMobilePendingInbox.ts new file mode 100644 index 0000000000..02621fcf0b --- /dev/null +++ b/src/modules/MobileRemote/app/useMobilePendingInbox.ts @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { createLogger } from "@src/hooks/logger"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { isPendingPermissions } from "../connection/sessionDiscoveryContract"; +import type { MobilePendingPermission } from "../connection/types"; +import type { MobileRemoteRuntimePort } from "../platform/types"; + +export interface MobilePendingInbox { + items: MobilePendingPermission[]; + phase: "syncing" | "ready" | "error" | "unsupported"; + complete: boolean; + refresh: () => void; +} + +/** One connection-scoped snapshot owner, coalescing pushes; never polls. */ +export function useMobilePendingInbox({ + client, + scope, + online, + supported, + runtime, + onSnapshot, +}: { + client: MobileRpcClient | null; + scope: string; + online: boolean; + supported: boolean; + runtime: MobileRemoteRuntimePort; + onSnapshot: (items: MobilePendingPermission[]) => void; +}): MobilePendingInbox { + const [state, setState] = useState<{ + scope: string; + items: MobilePendingPermission[]; + phase: MobilePendingInbox["phase"]; + complete: boolean; + }>({ scope, items: [], phase: "syncing", complete: false }); + const refreshRef = useRef(() => {}); + const refresh = useCallback(() => refreshRef.current(), []); + useEffect(() => { + if (!supported) return; + let disposed = false; + let inFlight = false; + let dirty = true; + let version = 0; + const read = async () => { + if ( + disposed || + inFlight || + !dirty || + !online || + !supported || + !client || + runtime.isHidden() + ) + return; + dirty = false; + inFlight = true; + const started = version; + setState((prev) => ({ + scope, + items: prev.scope === scope ? prev.items : [], + phase: "syncing", + complete: prev.scope === scope && prev.complete, + })); + try { + const reply = await client.call<{ + interactions: MobilePendingPermission[]; + complete: boolean; + }>("interaction/pending_all", {}); + if (disposed || started !== version) return; + if ( + !reply || + !isPendingPermissions(reply.interactions) || + typeof reply.complete !== "boolean" + ) + throw new Error("Invalid pending snapshot"); + setState({ + scope, + items: reply.interactions, + phase: "ready", + complete: reply.complete, + }); + // An incomplete snapshot cannot authoritatively delete missing prompts. + if (reply.complete) onSnapshot(reply.interactions); + } catch { + if (!disposed && started === version) + setState((prev) => ({ + ...prev, + scope, + items: prev.scope === scope ? prev.items : [], + phase: "error", + })); + } finally { + inFlight = false; + if (!disposed && dirty) + void read().catch((error) => + logger.warn("Background operation failed", error) + ); + } + }; + const invalidate = () => { + version++; + dirty = true; + void read().catch((error) => + logger.warn("Background operation failed", error) + ); + }; + refreshRef.current = invalidate; + const unsubscribe = client?.onNotification((method) => { + if (method === "interaction/pending_changed") invalidate(); + }); + const unwatch = runtime.subscribeVisibility(() => { + if (!runtime.isHidden()) invalidate(); + }); + void read().catch((error) => + logger.warn("Background operation failed", error) + ); + return () => { + disposed = true; + refreshRef.current = () => {}; + unsubscribe?.(); + unwatch(); + }; + }, [client, scope, online, supported, runtime, onSnapshot]); + const scoped = + state.scope === scope + ? state + : { items: [], phase: "syncing" as const, complete: false }; + return { + ...scoped, + phase: supported ? scoped.phase : "unsupported", + refresh, + }; +} + +const logger = createLogger("useMobilePendingInbox"); diff --git a/src/modules/MobileRemote/app/useMobilePermissions.discovery.test.ts b/src/modules/MobileRemote/app/useMobilePermissions.discovery.test.ts new file mode 100644 index 0000000000..be1671bc1b --- /dev/null +++ b/src/modules/MobileRemote/app/useMobilePermissions.discovery.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionSheetRequest } from "@src/components/PermissionPrompt"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { useMobilePermissions } from "./useMobilePermissions"; + +const request = ( + requestId: string, + sessionId = "session-a" +): PermissionSheetRequest => ({ + requestId, + sessionId, + origin: "rust_agent", + toolName: "shell", + toolArgs: { command: "pwd" }, +}); + +describe("useMobilePermissions inbox navigation", () => { + let root: ReturnType; + let current: ReturnType; + let sessionId: string | null; + let call: ReturnType; + let requireWritableClient: () => MobileRpcClient; + let renderedErrors: { requestId: string | undefined; failed: boolean }[]; + function Harness() { + const value = useMobilePermissions({ + sessionId, + demoMode: false, + requireWritableClient, + }); + renderedErrors.push({ + requestId: value.activePermission?.requestId, + failed: value.permissionFailed, + }); + React.useLayoutEffect(() => { + current = value; + }); + return null; + } + const render = () => + act(async () => root.render(React.createElement(Harness))); + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + sessionId = null; + renderedErrors = []; + call = vi.fn().mockResolvedValue({}); + const client = { call } as unknown as MobileRpcClient; + requireWritableClient = () => client; + }); + afterEach(async () => { + await act(async () => root.unmount()); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("selects the clicked pending request instead of the first request in that session", async () => { + await render(); + await act(async () => { + current.reconcilePermissions([request("first"), request("clicked")]); + current.focusPermission("clicked"); + }); + sessionId = "session-a"; + await render(); + expect(current.activePermission?.requestId).toBe("clicked"); + await act(async () => current.respondPermission("allow")); + expect(call).toHaveBeenCalledWith("interaction/respond_permission", { + sessionId: "session-a", + requestId: "clicked", + response: "allow", + origin: "rust_agent", + }); + expect(current.activePermission?.requestId).toBe("first"); + }); + + it("never selects a preferred request from another session", async () => { + sessionId = "session-b"; + await render(); + await act(async () => { + current.reconcilePermissions([ + request("clicked"), + request("other", "session-b"), + ]); + current.focusPermission("clicked"); + }); + expect(current.activePermission?.requestId).toBe("other"); + await act(async () => current.reconcilePermissions([])); + expect(current.activePermission).toBeNull(); + await act(async () => current.respondPermission("allow")); + expect(call).not.toHaveBeenCalled(); + }); + + it("session refresh does not delete unrelated pending requests", async () => { + sessionId = "session-b"; + await render(); + await act(async () => + current.reconcilePermissions([ + request("one"), + request("two", "session-b"), + ]) + ); + await act(async () => current.reconcileSessionPermissions("session-a", [])); + expect(current.activePermission?.requestId).toBe("two"); + expect(current.permissionQueueDepth).toBe(1); + }); + it("keeps failed requests with safe feedback and clears feedback when retry succeeds", async () => { + sessionId = "session-a"; + await render(); + await act(async () => + current.reconcilePermissions([request("one"), request("two")]) + ); + call.mockRejectedValueOnce(new Error("private-token")); + await act(async () => current.respondPermission("allow")); + expect(current.activePermission?.requestId).toBe("one"); + expect(current.permissionFailed).toBe(true); + expect(current.permissionSubmitting).toBe(false); + await act(async () => current.respondPermission("deny")); + expect(current.activePermission?.requestId).toBe("two"); + expect(current.permissionFailed).toBe(false); + }); + it("clears failures when the request is resolved, dismissed, or reset", async () => { + sessionId = "session-a"; + await render(); + for (const clear of [ + () => current.reconcileSessionPermissions("session-a", []), + () => current.dismissPermissionHead(), + () => current.resetPermissions(), + ]) { + await act(async () => current.reconcilePermissions([request("one")])); + call.mockRejectedValueOnce(new Error("offline")); + await act(async () => current.respondPermission("allow")); + expect(current.permissionFailed).toBe(true); + await act(async () => clear()); + expect(current.permissionFailed).toBe(false); + expect(current.activePermission).toBeNull(); + } + }); + it("ignores a late failure after switching request and preserves the next submission lock", async () => { + sessionId = "session-a"; + await render(); + await act(async () => + current.reconcilePermissions([request("one"), request("two")]) + ); + let rejectFirst!: (error: Error) => void; + let resolveNext!: (value: unknown) => void; + call.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirst = reject; + }) + ); + let first!: Promise; + await act(async () => { + first = current.respondPermission("allow"); + }); + await act(async () => current.focusPermission("two")); + call.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveNext = resolve; + }) + ); + let next!: Promise; + await act(async () => { + next = current.respondPermission("deny"); + }); + await act(async () => { + rejectFirst(new Error("old failure")); + await first; + }); + expect(current.permissionFailed).toBe(false); + expect(current.permissionSubmitting).toBe(true); + expect(current.activePermission?.requestId).toBe("two"); + await act(async () => { + resolveNext({}); + await next; + }); + expect(current.activePermission?.requestId).toBe("one"); + }); + it("does not carry failure feedback across session changes", async () => { + sessionId = "session-a"; + await render(); + await act(async () => + current.reconcilePermissions([ + request("one"), + request("two", "session-b"), + ]) + ); + call.mockRejectedValueOnce(new Error("offline")); + await act(async () => current.respondPermission("allow")); + sessionId = "session-b"; + renderedErrors = []; + await render(); + expect(current.permissionFailed).toBe(false); + expect(current.activePermission?.requestId).toBe("two"); + expect( + renderedErrors + .filter((value) => value.requestId === "two") + .every((value) => !value.failed) + ).toBe(true); + }); +}); diff --git a/src/modules/MobileRemote/app/useMobilePermissions.ts b/src/modules/MobileRemote/app/useMobilePermissions.ts index a5b157ead0..e54e4441ac 100644 --- a/src/modules/MobileRemote/app/useMobilePermissions.ts +++ b/src/modules/MobileRemote/app/useMobilePermissions.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { PermissionSheetRequest } from "@src/components/PermissionPrompt"; @@ -24,11 +24,34 @@ export function useMobilePermissions({ }) { const [queue, setQueue] = useState({ queue: [] }); const [permissionSubmitting, setSubmitting] = useState(false); + const [failedRequestKey, setFailed] = useState(null); + const [preferredRequestId, focusPermission] = useState(null); + const head = + queue.queue.find( + (row) => + row.requestId === preferredRequestId && row.sessionId === sessionId + ) ?? peekPermissionRequest(queue, sessionId); + const requestKey = head + ? JSON.stringify([head.sessionId, head.requestId]) + : null; + const permissionFailed = + requestKey !== null && failedRequestKey === requestKey; const inFlight = useRef(null); + // A result belongs to the visible request, never the next prompt or session. + useEffect(() => { + inFlight.current = null; + setSubmitting(false); + setFailed(null); + return () => { + inFlight.current = null; + }; + }, [head?.sessionId, head?.requestId]); const resetPermissions = useCallback( (requests: PermissionSheetRequest[] = []) => { inFlight.current = null; setSubmitting(false); + setFailed(null); + focusPermission(null); setQueue({ queue: requests }); }, [] @@ -36,13 +59,30 @@ export function useMobilePermissions({ const receivePermissionEvent = useCallback((event: PermissionBusEnvelope) => { setQueue((previous) => reduceInteractionQueueFromBusEvent(previous, event)); }, []); + const reconcilePermissions = useCallback( + (requests: PermissionSheetRequest[]) => { + setQueue({ queue: requests }); + }, + [] + ); + const reconcileSessionPermissions = useCallback( + (sessionId: string, requests: PermissionSheetRequest[]) => { + setQueue((previous) => ({ + queue: [ + ...previous.queue.filter((row) => row.sessionId !== sessionId), + ...requests.filter((row) => row.sessionId === sessionId), + ], + })); + }, + [] + ); const respondPermission = useCallback( async (response: "allow" | "deny" | "always_allow") => { - const head = peekPermissionRequest(queue, sessionId); if (!head || inFlight.current) return; const token = Symbol("permission-response"); inFlight.current = token; setSubmitting(true); + setFailed(null); try { if (!demoMode) { await requireWritableClient().call("interaction/respond_permission", { @@ -56,6 +96,9 @@ export function useMobilePermissions({ setQueue((previous) => dequeuePermissionRequest(previous, head.requestId) ); + } catch { + // Keep the request available for retry; transport details can contain secrets. + if (inFlight.current === token) setFailed(requestKey); } finally { if (inFlight.current === token) { inFlight.current = null; @@ -63,23 +106,26 @@ export function useMobilePermissions({ } } }, - [queue, sessionId, demoMode, requireWritableClient] + [head, requestKey, demoMode, requireWritableClient] ); const dismissPermissionHead = useCallback(() => { if (inFlight.current) return; - const head = peekPermissionRequest(queue, sessionId); if (head) setQueue((previous) => dequeuePermissionRequest(previous, head.requestId) ); - }, [queue, sessionId]); + }, [head]); return { - activePermission: peekPermissionRequest(queue, sessionId), + activePermission: head, + focusPermission, permissionQueueDepth: countPermissionRequests(queue, sessionId), permissionSubmitting, + permissionFailed, respondPermission, dismissPermissionHead, resetPermissions, receivePermissionEvent, + reconcilePermissions, + reconcileSessionPermissions, }; } diff --git a/src/modules/MobileRemote/app/useMobileReadStateSync.ts b/src/modules/MobileRemote/app/useMobileReadStateSync.ts new file mode 100644 index 0000000000..d3f56a401f --- /dev/null +++ b/src/modules/MobileRemote/app/useMobileReadStateSync.ts @@ -0,0 +1,40 @@ +import { + useEffect, + useLayoutEffect, + useMemo, + useSyncExternalStore, +} from "react"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { + type MobileReadStateSync, + createMobileReadStateSync, +} from "./mobileReadStateSync"; + +export function useMobileReadStateSync( + client: MobileRpcClient | null, + scope: string +) { + const sync = useMemo(() => createMobileReadStateSync(), []); + useLayoutEffect(() => { + sync.connect(client, scope); + return () => sync.connect(null, scope); + }, [client, scope, sync]); + useEffect(() => () => sync.dispose(), [sync]); + return sync; +} + +const emptyVisited: ReadonlyMap = new Map(); +const emptySnapshot = () => emptyVisited; +const emptySubscribe = () => () => undefined; + +/** Only the visible session list subscribes, not the entire connection/chat provider. */ +export function useMobileVisitedSessions( + sync: MobileReadStateSync | undefined +) { + return useSyncExternalStore( + sync?.subscribe ?? emptySubscribe, + sync?.getSnapshot ?? emptySnapshot, + sync?.getSnapshot ?? emptySnapshot + ); +} diff --git a/src/modules/MobileRemote/app/useMobileRemoteState.ts b/src/modules/MobileRemote/app/useMobileRemoteState.ts new file mode 100644 index 0000000000..94d202354b --- /dev/null +++ b/src/modules/MobileRemote/app/useMobileRemoteState.ts @@ -0,0 +1,313 @@ +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { MobileAuthContext } from "../auth/MobileAuthContext"; +import { + createMobileComposerDraftStore, + mobileComposerDesktopScope, +} from "../components/composer/mobileComposerDraftStore"; +import { type MobileRpcClient } from "../connection/mobileRpcClient"; +import { createRemoteReconnectController } from "../connection/remoteReconnectController"; +import type { + MobileConnectionConfig, + MobileConnectionState, + MobilePairedDesktopSummary, +} from "../connection/types"; +import { DEMO_PERMISSION_REQUEST } from "../demo/demoFixtures"; +import { useMobileRemotePlatform } from "../platform"; +import type { MobileRemoteProvidersProps } from "./MobileRemoteContext"; +import { useMobilePendingInbox } from "./useMobilePendingInbox"; +import { useMobilePermissions } from "./useMobilePermissions"; +import { useMobileReadStateSync } from "./useMobileReadStateSync"; +import { useMobileSend } from "./useMobileSend"; +import { useMobileSessionList } from "./useMobileSessionList"; +import { useMobileSessionModel } from "./useMobileSessionModel"; +import { useMobileTranscript } from "./useMobileTranscript"; + +export function useMobileRemoteState({ + authUserId, + relayUrl, + demoByDefault = true, + suppressInitialBootstrap = false, +}: Omit) { + const platform = useMobileRemotePlatform(); + const auth = useContext(MobileAuthContext); + const draftScope = JSON.stringify([ + authUserId, + auth?.session.supabaseUrl, + relayUrl, + ]); + const draftStore = useMemo(() => { + // A new authenticated desktop scope owns a fresh draft store. + void draftScope; + return createMobileComposerDraftStore(); + }, [draftScope]); + useEffect(() => () => draftStore.clear(), [draftStore]); + const authRef = useRef(auth); + useLayoutEffect(() => { + authRef.current = auth; + }, [auth]); + const preparationRef = useRef(null); + const clientRef = useRef(null); + const [rpc, setRpc] = useState(null); + const permissionRevisionRef = useRef(0); + const socketRef = useRef(null); + const activeSessionRef = useRef(null); + const unsubscribeRpcRef = useRef<(() => void) | null>(null); + const activeConfigRef = useRef(null); + const generationRef = useRef(0); + const selectionIntentRef = useRef(0); + const retryFlightRef = useRef | null>(null); + const recoverRef = useRef< + (config: MobileConnectionConfig, generation: number) => Promise + >(async () => undefined); + const isCurrentGeneration = useCallback( + (generation: number) => generation === generationRef.current, + [] + ); + const recoverConnection = useCallback( + (config: MobileConnectionConfig, generation: number) => + recoverRef.current(config, generation), + [] + ); + const reconnect = useMemo( + () => + createRemoteReconnectController( + platform.runtime, + // Factory stores this predicate; it only reads refs when a recovery runs. + // eslint-disable-next-line react-hooks/refs + isCurrentGeneration, + // Factory stores this callback without invoking it during render. + // eslint-disable-next-line react-hooks/refs + recoverConnection + ), + [platform.runtime, isCurrentGeneration, recoverConnection] + ); + const connectionWriteChainRef = useRef>(Promise.resolve()); + const inventoryRevisionRef = useRef(0); + const scheduleReconnectRef = useRef< + (config: MobileConnectionConfig, generation: number) => void + >(() => undefined); + + const [connection, setConnection] = useState({ + status: "disconnected", + presence: "unknown", + demoMode: demoByDefault && !suppressInitialBootstrap, + }); + const [connectionConfig, setConnectionConfig] = + useState(null); + const [bootstrapPending, setBootstrapPending] = useState( + !suppressInitialBootstrap + ); + const [pairedDesktops, setPairedDesktops] = useState< + MobilePairedDesktopSummary[] + >([]); + const connectionRef = useRef(connection); + useLayoutEffect(() => { + connectionRef.current = connection; + }, [connection]); + const readStateSync = useMobileReadStateSync( + connection.status === "connected" && + connection.presence === "online" && + connection.capabilities?.sessionReadState === true + ? rpc + : null, + JSON.stringify([ + authUserId, + auth?.session.supabaseUrl, + mobileComposerDesktopScope(connectionConfig, connection), + ]) + ); + const { sessions, sessionsHasMore, requestSessionList, resetSessions } = + useMobileSessionList(clientRef); + const { + openingClient, + openedSession, + releaseOpening, + transcript, + setTranscript, + transcriptView, + beginLoad, + resetTranscript, + showDemoTranscript, + failLoad, + invalidateTranscriptRequests, + requestSessionSnapshot, + refreshSubscribedSession, + receiveSnapshot, + selectRound, + retrySelectedRound, + } = useMobileTranscript({ + clientRef, + connectionRef, + activeSessionRef, + connection, + }); + const requireWritableClient = useCallback((): MobileRpcClient => { + const client = clientRef.current; + if ( + !client || + connection.status !== "connected" || + connection.presence !== "online" + ) { + throw new Error("Desktop is offline"); + } + if (connection.tier === "read_only") { + throw new Error("This device has read-only access"); + } + return client; + }, [connection.presence, connection.status, connection.tier]); + + const { + activePermission, + focusPermission, + permissionQueueDepth, + permissionSubmitting, + permissionFailed, + respondPermission, + dismissPermissionHead, + resetPermissions, + receivePermissionEvent, + reconcilePermissions, + reconcileSessionPermissions, + } = useMobilePermissions({ + sessionId: transcript.sessionId, + demoMode: connection.demoMode, + requireWritableClient, + }); + + const pendingInbox = useMobilePendingInbox({ + client: rpc, + scope: JSON.stringify([ + authUserId, + relayUrl, + connectionConfig?.desktopId ?? connection.desktopId, + connectionConfig?.host, + connectionConfig?.port, + ]), + online: + connection.status === "connected" && connection.presence === "online", + supported: connection.capabilities?.pendingInteractions === true, + runtime: platform.runtime, + onSnapshot: reconcilePermissions, + }); + + const { + sessionModel, + refreshSessionModel, + loadSessionModels, + setSessionModel, + resetSessionModel, + } = useMobileSessionModel({ + clientRef, + connectionRef, + activeSessionRef, + requireWritableClient, + }); + + const onDemoSend = useCallback( + (sessionId: string) => + resetPermissions([{ ...DEMO_PERMISSION_REQUEST, sessionId }]), + [resetPermissions] + ); + const { + sendStatus, + resetSend, + receiveTerminal, + receiveSendStatus, + sendMessage, + } = useMobileSend({ + demoMode: connection.demoMode, + runtime: platform.runtime, + model: sessionModel.config?.model, + requireWritableClient, + setTranscript, + onDemoSend, + }); + + return { + platform, + auth, + draftStore, + authRef, + preparationRef, + clientRef, + rpc, + setRpc, + permissionRevisionRef, + socketRef, + activeSessionRef, + unsubscribeRpcRef, + activeConfigRef, + generationRef, + selectionIntentRef, + retryFlightRef, + recoverRef, + reconnect, + connectionWriteChainRef, + inventoryRevisionRef, + scheduleReconnectRef, + connection, + setConnection, + connectionConfig, + setConnectionConfig, + bootstrapPending, + setBootstrapPending, + pairedDesktops, + setPairedDesktops, + connectionRef, + readStateSync, + sessions, + sessionsHasMore, + requestSessionList, + resetSessions, + openingClient, + openedSession, + releaseOpening, + transcript, + setTranscript, + transcriptView, + beginLoad, + resetTranscript, + showDemoTranscript, + failLoad, + invalidateTranscriptRequests, + requestSessionSnapshot, + refreshSubscribedSession, + receiveSnapshot, + selectRound, + retrySelectedRound, + requireWritableClient, + activePermission, + focusPermission, + permissionQueueDepth, + permissionSubmitting, + permissionFailed, + respondPermission, + dismissPermissionHead, + resetPermissions, + receivePermissionEvent, + reconcilePermissions, + reconcileSessionPermissions, + pendingInbox, + sessionModel, + refreshSessionModel, + loadSessionModels, + setSessionModel, + resetSessionModel, + onDemoSend, + sendStatus, + resetSend, + receiveTerminal, + receiveSendStatus, + sendMessage, + }; +} +export type MobileRemoteState = ReturnType; diff --git a/src/modules/MobileRemote/app/useMobileRpcNotifications.ts b/src/modules/MobileRemote/app/useMobileRpcNotifications.ts index 2b8c770275..685efc9e90 100644 --- a/src/modules/MobileRemote/app/useMobileRpcNotifications.ts +++ b/src/modules/MobileRemote/app/useMobileRpcNotifications.ts @@ -1,54 +1,79 @@ -import { type Dispatch, type SetStateAction, useCallback } from "react"; +import { useCallback } from "react"; import type { MobileConnectionState } from "../connection/types"; import type { PermissionBusEnvelope } from "../lib/interactionQueue"; import type { TranscriptSnapshotEnvelope } from "../lib/transcriptLoadState"; -import type { MobileConnectionRefs } from "./useMobileConnectionRefs"; -import type { useMobilePermissions } from "./useMobilePermissions"; -import { - terminalSignalFromBusEvent, - type useMobileSend, -} from "./useMobileSend"; -import type { useMobileSessionList } from "./useMobileSessionList"; -import type { useMobileTranscript } from "./useMobileTranscript"; +import type { MobileRemoteState } from "./useMobileRemoteState"; +import { terminalSignalFromBusEvent } from "./useMobileSend"; -type TranscriptApi = ReturnType; -type SendApi = ReturnType; - -interface UseMobileRpcNotificationsParams { - refs: Pick; - setConnection: Dispatch>; - requestSessionList: ReturnType< - typeof useMobileSessionList - >["requestSessionList"]; - receivePermissionEvent: ReturnType< - typeof useMobilePermissions - >["receivePermissionEvent"]; - receiveTerminal: SendApi["receiveTerminal"]; - receiveSendStatus: SendApi["receiveSendStatus"]; - receiveSnapshot: TranscriptApi["receiveSnapshot"]; - refreshSubscribedSession: TranscriptApi["refreshSubscribedSession"]; -} - -/** Routes desktop-originated RPC notifications to their owning hook. */ -export function useMobileRpcNotifications({ - refs, - setConnection, - requestSessionList, - receivePermissionEvent, - receiveTerminal, - receiveSendStatus, - receiveSnapshot, - refreshSubscribedSession, -}: UseMobileRpcNotificationsParams) { - const { clientRef, activeSessionRef } = refs; +export function useMobileRpcNotifications( + state: MobileRemoteState, + releaseTransport: (close: boolean) => void +) { + const { + clientRef, + permissionRevisionRef, + activeSessionRef, + activeConfigRef, + generationRef, + scheduleReconnectRef, + setConnection, + connectionRef, + requestSessionList, + refreshSubscribedSession, + receiveSnapshot, + receivePermissionEvent, + receiveTerminal, + receiveSendStatus, + } = state; const handleRpcNotification = useCallback( (method: string, params: Record | undefined) => { + if (method === "interaction/pending_changed") { + permissionRevisionRef.current++; + return; + } if (method === "relay/presence") { - setConnection((prev) => ({ - ...prev, + const previous = connectionRef.current; + const next: MobileConnectionState = { + ...previous, presence: params?.online === true ? "online" : "offline", - })); + }; + // Relay can retain the phone socket while replacing the Desktop actor. + // Presence is an invalidation edge, not just a status-dot update. Write + // the edge synchronously so duplicate notifications cannot refresh twice. + connectionRef.current = next; + setConnection(next); + const client = clientRef.current; + if ( + client && + previous.status === "connected" && + previous.presence !== "online" && + next.presence === "online" + ) { + const generation = generationRef.current; + void requestSessionList(client).catch(() => { + if ( + clientRef.current !== client || + generationRef.current !== generation || + connectionRef.current.presence !== "online" + ) + return; + const config = activeConfigRef.current; + if (!config || config.pairingCode) return; + // A failed recovery must not leave an empty, apparently online UI. + // Reuse the bounded, visibility-aware reconnect owner. + releaseTransport(true); + setConnection((prev) => ({ + ...prev, + status: "connecting", + presence: "offline", + })); + scheduleReconnectRef.current(config, generation); + }); + // Desktop's recreated actor also lost its active transcript subscription. + if (activeSessionRef.current) + refreshSubscribedSession(activeSessionRef.current); + } return; } if (method === "orgii/event") { @@ -85,15 +110,21 @@ export function useMobileRpcNotifications({ } }, [ + permissionRevisionRef, + connectionRef, + setConnection, + clientRef, + generationRef, requestSessionList, + activeSessionRef, + refreshSubscribedSession, + activeConfigRef, + releaseTransport, + scheduleReconnectRef, receivePermissionEvent, receiveTerminal, - receiveSendStatus, receiveSnapshot, - refreshSubscribedSession, - activeSessionRef, - clientRef, - setConnection, + receiveSendStatus, ] ); diff --git a/src/modules/MobileRemote/app/useMobileSessionActions.ts b/src/modules/MobileRemote/app/useMobileSessionActions.ts index 0c53147987..4b5b42b272 100644 --- a/src/modules/MobileRemote/app/useMobileSessionActions.ts +++ b/src/modules/MobileRemote/app/useMobileSessionActions.ts @@ -1,56 +1,34 @@ -import { type RefObject, useCallback } from "react"; +import { useCallback } from "react"; -import type { MobileRpcClient } from "../connection/mobileRpcClient"; -import type { MobileConnectionState } from "../connection/types"; -import { DEMO_SESSIONS } from "../demo/demoFixtures"; -import type { MobileConnectionRefs } from "./useMobileConnectionRefs"; -import type { useMobileSend } from "./useMobileSend"; -import type { useMobileSessionList } from "./useMobileSessionList"; -import type { useMobileSessionModel } from "./useMobileSessionModel"; -import type { useMobileTranscript } from "./useMobileTranscript"; - -type TranscriptApi = ReturnType; -type SessionListApi = ReturnType; -type SessionModelApi = ReturnType; +import type { PermissionSheetRequest } from "@src/components/PermissionPrompt"; -interface UseMobileSessionActionsParams { - connection: MobileConnectionState; - connectionRef: RefObject; - refs: Pick; - sessionsHasMore: boolean; - requireWritableClient: () => MobileRpcClient; - requestSessionList: SessionListApi["requestSessionList"]; - resetSessions: SessionListApi["resetSessions"]; - resetSend: ReturnType["resetSend"]; - beginLoad: TranscriptApi["beginLoad"]; - failLoad: TranscriptApi["failLoad"]; - showDemoTranscript: TranscriptApi["showDemoTranscript"]; - requestSessionSnapshot: TranscriptApi["requestSessionSnapshot"]; - resetTranscript: TranscriptApi["resetTranscript"]; - refreshSessionModel: SessionModelApi["refreshSessionModel"]; - resetSessionModel: SessionModelApi["resetSessionModel"]; -} - -/** Session-scoped RPC actions exposed through the provider context. */ -export function useMobileSessionActions({ - connection, - connectionRef, - refs, - sessionsHasMore, - requireWritableClient, - requestSessionList, - resetSessions, - resetSend, - beginLoad, - failLoad, - showDemoTranscript, - requestSessionSnapshot, - resetTranscript, - refreshSessionModel, - resetSessionModel, -}: UseMobileSessionActionsParams) { - const { clientRef, activeSessionRef } = refs; +import { isPendingPermissions } from "../connection/sessionDiscoveryContract"; +import { DEMO_SESSIONS } from "../demo/demoFixtures"; +import type { MobileRemoteState } from "./useMobileRemoteState"; +export function useMobileSessionActions(state: MobileRemoteState) { + const { + clientRef, + permissionRevisionRef, + activeSessionRef, + connection, + connectionRef, + readStateSync, + sessionsHasMore, + requestSessionList, + resetSessions, + releaseOpening, + beginLoad, + resetTranscript, + showDemoTranscript, + failLoad, + requestSessionSnapshot, + requireWritableClient, + reconcileSessionPermissions, + refreshSessionModel, + resetSessionModel, + resetSend, + } = state; const refreshSessions = useCallback(async () => { if (connection.demoMode) { resetSessions(DEMO_SESSIONS); @@ -58,20 +36,22 @@ export function useMobileSessionActions({ } const client = clientRef.current; if (!client || connection.presence !== "online") return; + readStateSync.refresh(); await requestSessionList(client); }, [ connection.demoMode, connection.presence, + clientRef, + readStateSync, requestSessionList, resetSessions, - clientRef, ]); const loadMoreSessions = useCallback(async () => { const client = clientRef.current; if (!client || connection.presence !== "online" || !sessionsHasMore) return; await requestSessionList(client, true); - }, [connection.presence, sessionsHasMore, requestSessionList, clientRef]); + }, [clientRef, connection.presence, sessionsHasMore, requestSessionList]); const subscribeSession = useCallback( async (sessionId: string) => { @@ -96,39 +76,65 @@ export function useMobileSessionActions({ subscriptionGeneration ); if (!applied) return; - await refreshSessionModel(sessionId); + const canonicalId = activeSessionRef.current; + if (!canonicalId || clientRef.current !== client) return; + // Model hydration must progress even when permission recovery stalls/fails. + const modelReady = refreshSessionModel(canonicalId); + if (currentConnection.capabilities?.pendingInteractions) { + const revision = permissionRevisionRef.current; + const pending = await client + .call<{ + interactions: PermissionSheetRequest[]; + complete: boolean; + }>("interaction/pending", { sessionId: canonicalId }) + .catch(() => null); + if ( + clientRef.current === client && + activeSessionRef.current === canonicalId && + revision === permissionRevisionRef.current && + pending?.complete && + isPendingPermissions(pending.interactions) + ) { + reconcileSessionPermissions(canonicalId, pending.interactions); + } + } + await modelReady; }, [ - refreshSessionModel, - requestSessionSnapshot, + activeSessionRef, resetSend, beginLoad, - failLoad, - showDemoTranscript, - activeSessionRef, - clientRef, connectionRef, + clientRef, + requestSessionSnapshot, + refreshSessionModel, + showDemoTranscript, + failLoad, + permissionRevisionRef, + reconcileSessionPermissions, ] ); const unsubscribeSession = useCallback(async () => { const sessionId = activeSessionRef.current; + const releasedOpening = releaseOpening(); activeSessionRef.current = null; resetTranscript(); resetSend(); resetSessionModel(); const currentConnection = connectionRef.current; - if (currentConnection.demoMode || !sessionId) return; + if (currentConnection.demoMode || !sessionId || releasedOpening) return; if (clientRef.current && currentConnection.presence === "online") { await clientRef.current.call("session/unsubscribe", { sessionId }); } }, [ - resetSessionModel, - resetSend, - resetTranscript, activeSessionRef, - clientRef, + releaseOpening, + resetTranscript, + resetSend, + resetSessionModel, connectionRef, + clientRef, ]); const openSessionFileInDesktop = useCallback( diff --git a/src/modules/MobileRemote/app/useMobileSessionIdentity.ts b/src/modules/MobileRemote/app/useMobileSessionIdentity.ts new file mode 100644 index 0000000000..72f8419886 --- /dev/null +++ b/src/modules/MobileRemote/app/useMobileSessionIdentity.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { + MobileSessionIdentityInvalidated, + cachedMobileSessionIdentity, + invalidateMobileSessionIdentities, + resolveMobileSessionIdentity, +} from "../connection/mobileSessionIdentityCache"; + +type Resolution = { + client: MobileRpcClient; + requested: string; + sessionId?: string; + managed?: boolean; + error?: string; +}; + +/** Resolve before mounting chat so config, send, stop and subscription share an owner. */ +export function useMobileSessionIdentity( + client: MobileRpcClient | null, + requested: string, + supported: boolean, + online: boolean +) { + const [resolution, setResolution] = useState(null); + const [previousOnline, setPreviousOnline] = useState(online); + const [attempt, setAttempt] = useState(0); + // A relay can restore Desktop on the same phone socket. An error belongs + // to the old presence interval, not the recovered actor. Adjust it before + // committing children; keep successful history readable while offline. + if (previousOnline !== online) { + setPreviousOnline(online); + if (online || resolution?.error) setResolution(null); + } + const retry = useCallback(() => { + setResolution(null); + setAttempt((value) => value + 1); + }, []); + const cached = + client && online + ? cachedMobileSessionIdentity(client, requested) + : undefined; + const cachedResolution = useMemo( + () => (cached && client ? { client, requested, ...cached } : null), + [cached, client, requested] + ); + // Retain a cache hit in this mounted view as well, so taking the connection + // offline does not erase already displayed read-only content. + if (cachedResolution && resolution !== cachedResolution) { + setResolution(cachedResolution); + } + const current: Resolution | null = + resolution?.client === client && + (resolution?.requested === requested || resolution?.sessionId === requested) + ? resolution + : cachedResolution; + useEffect(() => { + if (client && !online) invalidateMobileSessionIdentities(client); + if (!supported || !online || !client || current) return; + let active = true; + void resolveMobileSessionIdentity(client, requested) + .then((result) => { + if (active) { + setResolution({ + client, + requested, + sessionId: result.sessionId, + managed: result.managed, + }); + } + }) + .catch((error: unknown) => { + if (active) { + if (error instanceof MobileSessionIdentityInvalidated) { + setAttempt((value) => value + 1); + return; + } + setResolution({ + client, + requested, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + return () => { + active = false; + }; + }, [client, requested, supported, online, attempt, current]); + return { + sessionId: supported ? current?.sessionId : requested, + managed: supported && current?.managed === true, + error: current?.error, + retry, + }; +} diff --git a/src/modules/MobileRemote/app/useMobileSessionList.test.ts b/src/modules/MobileRemote/app/useMobileSessionList.test.ts index acfd5f1b33..e296c48aa1 100644 --- a/src/modules/MobileRemote/app/useMobileSessionList.test.ts +++ b/src/modules/MobileRemote/app/useMobileSessionList.test.ts @@ -48,7 +48,10 @@ describe("useMobileSessionList", () => { await act(async () => roster.resetSessions()); expect(roster.sessionsHasMore).toBe(false); await act(async () => roster.requestSessionList(client)); - expect(call).toHaveBeenLastCalledWith("session/list", { offset: 0 }); + expect(call).toHaveBeenLastCalledWith("session/list", { + offset: 0, + limit: 200, + }); } finally { await act(async () => root.unmount()); env.IS_REACT_ACT_ENVIRONMENT = false; diff --git a/src/modules/MobileRemote/app/useMobileSessionList.ts b/src/modules/MobileRemote/app/useMobileSessionList.ts index f42c937c88..214a740164 100644 --- a/src/modules/MobileRemote/app/useMobileSessionList.ts +++ b/src/modules/MobileRemote/app/useMobileSessionList.ts @@ -7,6 +7,7 @@ import { } from "react"; import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { hasValidSessionPresentation } from "../connection/sessionDiscoveryContract"; import type { MobileSessionRow } from "../connection/types"; /** Owns roster pagination and invalidation, not the transport lifetime. */ @@ -17,6 +18,7 @@ export function useMobileSessionList( const [sessionsHasMore, setSessionsHasMore] = useState(false); const sessionNextOffsetRef = useRef(0); const sessionListGenerationRef = useRef(0); + const snapshotRevisionRef = useRef(0); const flightRef = useRef<{ client: MobileRpcClient; generation: number; @@ -30,6 +32,7 @@ export function useMobileSessionList( append: boolean, requestGeneration: number ) => { + const snapshotRevision = snapshotRevisionRef.current; const targetOffset = append ? sessionNextOffsetRef.current + 50 : Math.max(50, sessionNextOffsetRef.current); @@ -41,12 +44,23 @@ export function useMobileSessionList( } = {}; const rows: MobileSessionRow[] = []; do { - list = await client.call("session/list", { offset }); + list = await client.call("session/list", { + offset, + limit: 200, + }); if ( requestGeneration !== sessionListGenerationRef.current || + snapshotRevision !== snapshotRevisionRef.current || clientRef.current !== client ) return; + if ( + list.sessions && + (!Array.isArray(list.sessions) || + !list.sessions.every(hasValidSessionPresentation)) + ) { + throw new Error("Invalid session presentation metadata"); + } rows.push(...(list.sessions ?? [])); const next = list.nextOffset; if (!Number.isSafeInteger(next) || next! <= offset) { @@ -57,6 +71,7 @@ export function useMobileSessionList( } while (list.hasMore && offset < targetOffset); if ( requestGeneration !== sessionListGenerationRef.current || + snapshotRevision !== snapshotRevisionRef.current || clientRef.current !== client ) { return; @@ -88,7 +103,10 @@ export function useMobileSessionList( current.generation === sessionListGenerationRef.current ) { if (append) current.append = true; - else current.refresh = true; + else { + current.refresh = true; + snapshotRevisionRef.current += 1; + } return current.promise; } const flight = { @@ -99,7 +117,7 @@ export function useMobileSessionList( append, }; flightRef.current = flight; - flight.promise = Promise.resolve().then(async () => { + flight.promise = (async () => { try { while ( flightRef.current === flight && @@ -121,7 +139,7 @@ export function useMobileSessionList( } finally { if (flightRef.current === flight) flightRef.current = null; } - }); + })(); return flight.promise; }, [clientRef, readPage] diff --git a/src/modules/MobileRemote/app/useMobileSessionModel.test.ts b/src/modules/MobileRemote/app/useMobileSessionModel.test.ts index df9a100de1..3d744ef0dc 100644 --- a/src/modules/MobileRemote/app/useMobileSessionModel.test.ts +++ b/src/modules/MobileRemote/app/useMobileSessionModel.test.ts @@ -8,6 +8,112 @@ import type { MobileConnectionState } from "../connection/types"; import { useMobileSessionModel } from "./useMobileSessionModel"; describe("useMobileSessionModel", () => { + it("loads options only on demand, shares flights, retains config on error and rejects stale results", async () => { + const env = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + env.IS_REACT_ACT_ENVIRONMENT = true; + let finish!: (value: unknown) => void; + let fail!: (error: Error) => void; + const call = vi.fn((method: string, params: Record) => { + if (method === "session/config") + return Promise.resolve({ + sessionId: params.sessionId, + model: "current", + modelEditable: true, + }); + return new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + }); + const client = { call } as unknown as MobileRpcClient; + const clientRef = { current: client as MobileRpcClient | null }; + const activeSessionRef = { current: "a" as string | null }; + const connectionRef = { + current: { + status: "connected", + presence: "online", + demoMode: false, + } as MobileConnectionState, + }; + let model!: ReturnType; + function Harness() { + const value = useMobileSessionModel({ + clientRef, + activeSessionRef, + connectionRef, + requireWritableClient: () => client, + }); + React.useLayoutEffect(() => { + model = value; + }); + return null; + } + const root = createRoot(document.createElement("div")); + try { + await act(async () => root.render(React.createElement(Harness))); + await act(async () => model.refreshSessionModel("a")); + expect(call.mock.calls.map(([method]) => method)).toEqual([ + "session/config", + ]); + let first!: Promise; + await act(async () => { + first = model.loadSessionModels("a"); + expect(model.loadSessionModels("a")).toBe(first); + }); + expect(model.sessionModel.optionsLoading).toBe(true); + await act(async () => { + fail(new Error("catalog unavailable")); + await first; + }); + expect(model.sessionModel.config?.model).toBe("current"); + expect(model.sessionModel.optionsError).toBe("catalog unavailable"); + await act(async () => { + first = model.loadSessionModels("a"); + }); + await act(async () => { + finish({ models: [] }); + await first; + }); + expect(model.sessionModel.optionsError).toBeUndefined(); + expect(model.sessionModel.optionsLoading).toBe(false); + const count = call.mock.calls.length; + await act(async () => model.loadSessionModels("a")); + expect(call).toHaveBeenCalledTimes(count); + await act(async () => model.resetSessionModel()); + await act(async () => model.refreshSessionModel("a")); + await act(async () => { + first = model.loadSessionModels("a"); + }); + activeSessionRef.current = "b"; + await act(async () => { + model.resetSessionModel(); + await model.refreshSessionModel("b"); + }); + await act(async () => { + finish({ + models: [{ id: "stale", accountId: "a", accountLabel: "a" }], + }); + await first; + }); + expect(model.sessionModel.config?.sessionId).toBe("b"); + expect(model.sessionModel.options).toEqual([]); + call.mockResolvedValueOnce({ + sessionId: "b", + model: "readonly", + modelEditable: false, + }); + await act(async () => model.refreshSessionModel("b")); + const readonlyCount = call.mock.calls.length; + await act(async () => model.loadSessionModels("b")); + expect(call).toHaveBeenCalledTimes(readonlyCount); + } finally { + await act(async () => root.unmount()); + env.IS_REACT_ACT_ENVIRONMENT = false; + } + }); + it("does not apply a late model patch to a newly selected session", async () => { const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; diff --git a/src/modules/MobileRemote/app/useMobileSessionModel.ts b/src/modules/MobileRemote/app/useMobileSessionModel.ts index ce77a1d4fe..78ee0f46e0 100644 --- a/src/modules/MobileRemote/app/useMobileSessionModel.ts +++ b/src/modules/MobileRemote/app/useMobileSessionModel.ts @@ -51,12 +51,31 @@ export function useMobileSessionModel({ }) { const [sessionModel, setSessionModelState] = useState(INITIAL_SESSION_MODEL_STATE); + const sessionModelRef = useRef(sessionModel); + sessionModelRef.current = sessionModel; const sessionModelRequestRef = useRef(0); + const optionsGenerationRef = useRef(0); + const optionsFlightRef = useRef<{ + client: MobileRpcClient | null; + sessionId: string; + promise: Promise; + } | null>(null); + const optionsReadyRef = useRef<{ + client: MobileRpcClient | null; + sessionId: string; + } | null>(null); const refreshSessionModel = useCallback( async (sessionId: string) => { const requestGeneration = ++sessionModelRequestRef.current; + optionsReadyRef.current = null; + if (sessionModelRef.current.config?.sessionId !== sessionId) { + optionsGenerationRef.current += 1; + optionsFlightRef.current = null; + } setSessionModelState((prev) => ({ - ...prev, + ...(prev.config?.sessionId === sessionId + ? prev + : INITIAL_SESSION_MODEL_STATE), loading: true, error: undefined, })); @@ -81,14 +100,10 @@ export function useMobileSessionModel({ return; } try { - const [config, list] = await Promise.all([ - client.call("session/config", { - sessionId, - }), - client.call<{ models?: MobileModelOption[] }>("models/list", { - sessionId, - }), - ]); + const config = await client.call( + "session/config", + { sessionId } + ); if ( requestGeneration !== sessionModelRequestRef.current || activeSessionRef.current !== sessionId || @@ -96,12 +111,13 @@ export function useMobileSessionModel({ ) { return; } - setSessionModelState({ + setSessionModelState((prev) => ({ + ...prev, config, - options: list.models ?? [], loading: false, patching: false, - }); + options: prev.config?.sessionId === sessionId ? prev.options : [], + })); } catch (error) { if ( requestGeneration !== sessionModelRequestRef.current || @@ -119,6 +135,85 @@ export function useMobileSessionModel({ [clientRef, connectionRef, activeSessionRef] ); + const loadSessionModels = useCallback( + (sessionId: string): Promise => { + const client = clientRef.current; + if ( + optionsFlightRef.current?.client === client && + optionsFlightRef.current.sessionId === sessionId + ) + return optionsFlightRef.current.promise; + if ( + optionsReadyRef.current?.client === client && + optionsReadyRef.current.sessionId === sessionId + ) + return Promise.resolve(); + if (activeSessionRef.current !== sessionId) return Promise.resolve(); + const config = sessionModelRef.current.config; + if (config?.sessionId !== sessionId || config.modelEditable !== true) + return Promise.resolve(); + if (connectionRef.current.demoMode) { + setSessionModelState((prev) => ({ + ...prev, + options: DEMO_MODEL_OPTIONS, + })); + return Promise.resolve(); + } + if (!client || connectionRef.current.presence !== "online") + return Promise.resolve(); + const generation = ++optionsGenerationRef.current; + const current = () => + generation === optionsGenerationRef.current && + clientRef.current === client && + activeSessionRef.current === sessionId; + setSessionModelState((prev) => ({ + ...prev, + optionsLoading: true, + optionsError: undefined, + })); + const promise = Promise.resolve().then(async () => { + try { + const list = await client.call<{ models: MobileModelOption[] }>( + "models/list", + { sessionId } + ); + if ( + !Array.isArray(list?.models) || + list.models.length > 256 || + list.models.some( + (option) => + !option || + typeof option.id !== "string" || + typeof option.accountId !== "string" || + typeof option.accountLabel !== "string" + ) + ) + throw new Error("Invalid model catalog"); + if (!current()) return; + optionsReadyRef.current = { client, sessionId }; + setSessionModelState((prev) => ({ + ...prev, + options: list.models, + optionsLoading: false, + })); + } catch (error) { + if (current()) + setSessionModelState((prev) => ({ + ...prev, + optionsLoading: false, + optionsError: toMobileRpcError(error).message, + })); + } finally { + if (optionsFlightRef.current?.promise === promise) + optionsFlightRef.current = null; + } + }); + optionsFlightRef.current = { client, sessionId, promise }; + return promise; + }, + [activeSessionRef, clientRef, connectionRef] + ); + const setSessionModel = useCallback( async (sessionId: string, option: MobileModelOption) => { const requestGeneration = ++sessionModelRequestRef.current; @@ -194,11 +289,15 @@ export function useMobileSessionModel({ const resetSessionModel = useCallback(() => { sessionModelRequestRef.current += 1; + optionsGenerationRef.current += 1; + optionsFlightRef.current = null; + optionsReadyRef.current = null; setSessionModelState(INITIAL_SESSION_MODEL_STATE); }, []); return { sessionModel, refreshSessionModel, + loadSessionModels, setSessionModel, resetSessionModel, }; diff --git a/src/modules/MobileRemote/app/useMobileSessionSearch.test.ts b/src/modules/MobileRemote/app/useMobileSessionSearch.test.ts new file mode 100644 index 0000000000..d33b8dc3ea --- /dev/null +++ b/src/modules/MobileRemote/app/useMobileSessionSearch.test.ts @@ -0,0 +1,417 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { createMobileRpcClient } from "../connection/mobileRpcClient"; +import { useMobileSessionSearch } from "./useMobileSessionSearch"; + +function deferred() { + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const row = (id: string) => ({ id, name: id, status: "idle" as const }); +const page = (id: string, nextOffset = 1, hasMore = false) => ({ + sessions: [row(id)], + nextOffset, + hasMore, +}); + +describe("useMobileSessionSearch", () => { + let root: ReturnType; + let current: ReturnType; + function Harness({ + client, + online = true, + }: { + client: MobileRpcClient | null; + online?: boolean; + }) { + const value = useMobileSessionSearch(client, online); + React.useLayoutEffect(() => { + current = value; + }); + return null; + } + const mount = async (call: ReturnType, online = true) => { + const client = { call } as unknown as MobileRpcClient; + await act(async () => + root.render(React.createElement(Harness, { client, online })) + ); + return client; + }; + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + }); + afterEach(async () => { + await act(async () => root.unmount()); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("releases the real RPC wait on supersession, cancel, offline and unmount", async () => { + const listeners = new Map void>(); + const send = vi.fn(); + const socket = { + readyState: 1, + send, + close: vi.fn(), + addEventListener: ( + name: string, + listener: (event: { data: string }) => void + ) => listeners.set(name, listener), + }; + const client = createMobileRpcClient(socket as unknown as WebSocket, { + setTimeout: (fn, ms) => window.setTimeout(fn, ms), + clearTimeout: (id) => window.clearTimeout(id), + }); + const render = (online = true) => + act(async () => + root.render(React.createElement(Harness, { client, online })) + ); + const reply = (id: number, value: string) => + listeners.get("message")!({ + data: JSON.stringify({ jsonrpc: "2.0", id, result: page(value) }), + }); + await render(); + await act(async () => { + void current.search("old"); + }); + await act(async () => { + void current.search("new"); + }); + expect(send).toHaveBeenCalledTimes(2); // Old RPC has never replied. + await act(async () => reply(2, "new")); + await act(async () => reply(1, "old")); + expect(current.sessions[0].id).toBe("new"); + await act(async () => { + void current.search("cancelled"); + }); + await act(async () => current.cancel()); + await act(async () => { + void current.search("after cancel"); + }); + expect(send).toHaveBeenCalledTimes(4); + await render(false); + await render(true); + await act(async () => { + void current.search("after reconnect"); + }); + expect(send).toHaveBeenCalledTimes(5); + await act(async () => root.render(null)); + await act(async () => { + reply(3, "cancelled"); + reply(4, "offline"); + reply(5, "unmounted"); + }); + expect(socket.close).not.toHaveBeenCalled(); + client.close(); + }); + + it("loads bounded server pages past the first 50 using the authoritative cursor", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(page("first", 125, true)) + .mockResolvedValueOnce(page("historic", 200)); + await mount(call); + expect(call).not.toHaveBeenCalled(); + await act(async () => current.search(" project ")); + expect(call).toHaveBeenNthCalledWith( + 1, + "session/list", + { + query: "project", + offset: 0, + limit: 50, + }, + expect.any(AbortSignal) + ); + await act(async () => current.loadNext()); + expect(call).toHaveBeenNthCalledWith( + 2, + "session/list", + { + query: "project", + offset: 125, + limit: 50, + }, + expect.any(AbortSignal) + ); + expect(current.sessions.map((item) => item.id)).toEqual(["historic"]); + expect(current.phase).toBe("ready"); + }); + + it("single-flights repeated submit and exposes the loading transition", async () => { + const request = deferred(); + const call = vi.fn().mockReturnValue(request.promise); + await mount(call); + await act(async () => { + void current.search("first"); + void current.search("first"); + }); + expect(call).toHaveBeenCalledTimes(1); + expect(current.phase).toBe("loading"); + await act(async () => request.resolve(page("first"))); + expect(current.phase).toBe("ready"); + }); + + it("cancel rejects late responses and queues a fresh query without overlapping RPCs", async () => { + const old = deferred(); + const call = vi + .fn() + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce(page("new")); + await mount(call); + await act(async () => { + void current.search("old"); + }); + await act(async () => current.cancel()); + expect(current).toMatchObject({ phase: "idle", query: "", sessions: [] }); + await act(async () => { + void current.search("new"); + }); + expect(call).toHaveBeenCalledTimes(1); + await act(async () => old.resolve(page("old"))); + expect(current.sessions.map((item) => item.id)).toEqual(["new"]); + expect(current.query).toBe("new"); + }); + + it("switching client clears prior rows and prevents stale desktop results", async () => { + const old = deferred(); + await mount(vi.fn().mockReturnValue(old.promise)); + await act(async () => { + void current.search("old"); + }); + const newer = vi.fn().mockResolvedValue(page("desktop-b")); + await mount(newer); + expect(current).toMatchObject({ query: "", sessions: [], phase: "idle" }); + await act(async () => current.search("new")); + await act(async () => old.resolve(page("desktop-a"))); + expect(current.sessions.map((item) => item.id)).toEqual(["desktop-b"]); + }); + + it("failed query can be retried and an empty successful response is ready", async () => { + const call = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ sessions: [], nextOffset: 0, hasMore: false }); + await mount(call); + await act(async () => current.search("none")); + expect(current.phase).toBe("error"); + await act(async () => current.search("none")); + expect(current).toMatchObject({ + phase: "ready", + sessions: [], + hasMore: false, + }); + }); + + it("blocks offline and empty queries without issuing a request", async () => { + const call = vi.fn(); + const client = await mount(call, false); + await act(async () => current.search("query")); + await act(async () => + root.render(React.createElement(Harness, { client })) + ); + await act(async () => current.search(" ")); + expect(call).not.toHaveBeenCalled(); + }); + + it("retries the failed history cursor while preserving the last successful page", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(page("first", 125, true)) + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce(page("historic", 200)); + await mount(call); + await act(async () => current.search("project")); + await act(async () => current.loadNext()); + expect(current.phase).toBe("error"); + expect(current.sessions.map((item) => item.id)).toEqual(["first"]); + await act(async () => current.retry()); + expect(call).toHaveBeenNthCalledWith( + 3, + "session/list", + { + query: "project", + offset: 125, + limit: 50, + }, + expect.any(AbortSignal) + ); + expect(current.sessions.map((item) => item.id)).toEqual(["historic"]); + expect(current.phase).toBe("ready"); + }); + + it("disconnect ends loading immediately and reconnect retries without accepting the old response", async () => { + const old = deferred(); + const fresh = deferred(); + const call = vi + .fn() + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(fresh.promise); + const client = await mount(call); + await act(async () => { + void current.search("project"); + }); + await act(async () => + root.render(React.createElement(Harness, { client, online: false })) + ); + expect(current.phase).toBe("error"); + await act(async () => current.retry()); + expect(call).toHaveBeenCalledTimes(1); + await act(async () => + root.render(React.createElement(Harness, { client, online: true })) + ); + expect(call).toHaveBeenCalledTimes(1); // No automatic scan on reconnect. + await act(async () => { + void current.retry(); + }); + await act(async () => old.resolve(page("stale"))); + expect(current.phase).toBe("loading"); + expect(current.sessions).toEqual([]); + await act(async () => fresh.resolve(page("fresh"))); + expect(current.sessions.map((item) => item.id)).toEqual(["fresh"]); + }); + + it("cancel discards the retry cursor and unrelated new queries start at zero", async () => { + const call = vi + .fn() + .mockResolvedValueOnce(page("first", 125, true)) + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce(page("new")); + await mount(call); + await act(async () => current.search("old")); + await act(async () => current.loadNext()); + await act(async () => current.cancel()); + await act(async () => current.retry()); + expect(call).toHaveBeenCalledTimes(2); + await act(async () => current.search("new")); + expect(call).toHaveBeenLastCalledWith( + "session/list", + { + query: "new", + offset: 0, + limit: 50, + }, + expect.any(AbortSignal) + ); + }); + + it("keeps the previous page readable offline without starting background work", async () => { + const call = vi.fn().mockResolvedValue(page("known", 50, true)); + const client = await mount(call); + await act(async () => current.search("project")); + await act(async () => + root.render(React.createElement(Harness, { client, online: false })) + ); + await act(async () => current.loadNext()); + expect(current.sessions.map((item) => item.id)).toEqual(["known"]); + expect(current.phase).toBe("ready"); + expect(call).toHaveBeenCalledTimes(1); + }); + + it("does not request another page after the cursor is exhausted or exceed the query bound", async () => { + const call = vi.fn().mockResolvedValue(page("only")); + await mount(call); + await act(async () => current.search("x".repeat(201))); + await act(async () => current.loadNext()); + await act(async () => current.retry()); + expect(call).not.toHaveBeenCalled(); + await act(async () => current.search("literal_100%")); + await act(async () => current.loadNext()); + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + "session/list", + { + query: "literal_100%", + offset: 0, + limit: 50, + }, + expect.any(AbortSignal) + ); + }); + + it("retains one bounded result page across repeated next-page requests", async () => { + const call = vi.fn().mockImplementation((_method, { offset }) => + Promise.resolve({ + sessions: Array.from({ length: 50 }, (_, index) => + row(`s-${offset + index}`) + ), + nextOffset: offset + 50, + hasMore: offset < 2500, + }) + ); + await mount(call); + await act(async () => current.search("project")); + for (let index = 0; index < 50; index++) { + await act(async () => current.loadNext()); + expect(current.sessions).toHaveLength(50); + } + expect(current.sessions[0].id).toBe("s-2500"); + expect(current.hasMore).toBe(false); + expect(call).toHaveBeenCalledTimes(51); + }); + + it("unmount invalidates the outstanding request without starting any follow-up work", async () => { + const request = deferred(); + const call = vi.fn().mockReturnValue(request.promise); + await mount(call); + await act(async () => { + void current.search("project"); + }); + await act(async () => root.render(null)); + await act(async () => request.reject(new Error("late timeout"))); + expect(call).toHaveBeenCalledTimes(1); + }); + + it("rejects a non-advancing cursor instead of repeating the same history page", async () => { + const call = vi + .fn() + .mockResolvedValue({ sessions: [], nextOffset: 0, hasMore: true }); + await mount(call); + await act(async () => current.search("test")); + expect(current.phase).toBe("error"); + }); + + it.each([ + null, + { + sessions: Array.from({ length: 51 }, (_, index) => row(String(index))), + nextOffset: 51, + hasMore: false, + }, + { sessions: [null], nextOffset: 1, hasMore: false }, + { + sessions: [{ ...row("bad"), updatedAtMs: 1e20 }], + nextOffset: 1, + hasMore: false, + }, + { + sessions: [{ ...row("bad"), status: ["idle"] }], + nextOffset: 1, + hasMore: false, + }, + { sessions: [row("bad")], nextOffset: 1, hasMore: "false" }, + { + sessions: Array.from({ length: 51 }, (_, id) => row(String(id))), + nextOffset: 51, + hasMore: true, + }, + ])("rejects malformed search payload at ingestion (%j)", async (payload) => { + const call = vi.fn().mockResolvedValue(payload); + await mount(call); + await act(async () => current.search("query")); + expect(current).toMatchObject({ phase: "error", sessions: [] }); + }); +}); diff --git a/src/modules/MobileRemote/app/useMobileSessionSearch.ts b/src/modules/MobileRemote/app/useMobileSessionSearch.ts new file mode 100644 index 0000000000..819753e09b --- /dev/null +++ b/src/modules/MobileRemote/app/useMobileSessionSearch.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { createLatestOnlySearchRunner } from "@src/util/latestOnlySearchRunner"; + +import type { MobileRpcClient } from "../connection/mobileRpcClient"; +import { isSessionSearchRows } from "../connection/sessionDiscoveryContract"; +import type { MobileSessionRow } from "../connection/types"; + +export interface MobileSearchPage { + sessions: MobileSessionRow[]; + nextOffset: number; + hasMore: boolean; +} + +interface SearchRequest { + query: string; + offset: number; +} + +interface SearchState extends MobileSearchPage { + client: MobileRpcClient | null; + phase: "idle" | "loading" | "ready" | "error"; + query: string; + request: SearchRequest | null; +} + +const emptySearch = (client: MobileRpcClient | null): SearchState => ({ + client, + sessions: [], + nextOffset: 0, + hasMore: false, + phase: "idle", + query: "", + request: null, +}); + +/** One active RPC plus the latest queued request, sharing Desktop's scheduler. */ +export function useMobileSessionSearch( + client: MobileRpcClient | null, + online: boolean +) { + const [result, setResult] = useState(() => emptySearch(client)); + const generation = useRef(0); + const activeRequest = useRef(null); + const runner = useRef + > | null>(null); + const pendingRequest = useRef<(SearchRequest & { token: number }) | null>( + null + ); + const connection = useRef({ client, online }); + connection.current = { client, online }; + const invalidate = useCallback(() => { + generation.current++; + pendingRequest.current = null; + runner.current?.clearPending(); + activeRequest.current?.abort(); + activeRequest.current = null; + }, []); + const cancel = useCallback(() => { + invalidate(); + setResult(emptySearch(client)); + }, [client, invalidate]); + useEffect(() => { + if (online) return; + // A relay socket can stay open while its desktop goes offline. Do not + // keep waiting for its RPC timeout or accept its late result on reconnect. + invalidate(); + setResult((previous) => + previous.phase === "loading" ? { ...previous, phase: "error" } : previous + ); + }, [online, invalidate]); + + useEffect(() => { + cancel(); + const scheduler = createLatestOnlySearchRunner< + SearchRequest & { token: number } + >(async (request) => { + if (!client) return; + const { query, offset, token } = request; + const isCurrent = () => + generation.current === token && + connection.current.client === client && + connection.current.online; + if (!isCurrent()) return; + const controller = new AbortController(); + activeRequest.current = controller; + try { + const reply = await client.call( + "session/list", + { + query, + offset, + limit: 50, + }, + controller.signal + ); + if (!isCurrent()) return; + if ( + !reply || + !isSessionSearchRows(reply.sessions) || + reply.sessions.length > 50 || + typeof reply.hasMore !== "boolean" || + !Number.isSafeInteger(reply.nextOffset) || + reply.nextOffset < offset || + (reply.hasMore && reply.nextOffset <= offset) + ) + throw new Error("Invalid search cursor"); + setResult({ + sessions: reply.sessions, + nextOffset: reply.nextOffset, + hasMore: reply.hasMore, + client, + query, + request, + phase: "ready", + }); + } catch { + if (isCurrent()) setResult((prev) => ({ ...prev, phase: "error" })); + } finally { + if (activeRequest.current === controller) activeRequest.current = null; + if (generation.current === token) pendingRequest.current = null; + } + }); + runner.current = scheduler; + return () => { + invalidate(); + scheduler.dispose(); + runner.current = null; + }; + }, [client, cancel, invalidate]); + const requestPage = useCallback( + (request: SearchRequest) => { + if (!client || !online) return Promise.resolve(); + const previous = pendingRequest.current; + if ( + previous?.query === request.query && + previous.offset === request.offset + ) + return Promise.resolve(); + const token = ++generation.current; + activeRequest.current?.abort(); + pendingRequest.current = { ...request, token }; + setResult((prev) => ({ + ...(request.offset > 0 && + prev.client === client && + prev.query === request.query + ? prev + : emptySearch(client)), + query: request.query, + request, + phase: "loading", + })); + return runner.current?.submit({ ...request, token }) ?? Promise.resolve(); + }, + [client, online] + ); + // Never expose a previous client's rows in the render before effect cleanup. + const scoped = result.client === client ? result : emptySearch(client); + const search = useCallback( + (query: string) => { + query = query.trim(); + if (!query || Array.from(query).length > 200) return Promise.resolve(); + return requestPage({ query, offset: 0 }); + }, + [requestPage] + ); + const loadNext = () => + scoped.phase === "ready" && scoped.hasMore + ? requestPage({ query: scoped.query, offset: scoped.nextOffset }) + : Promise.resolve(); + const retry = () => + scoped.phase === "error" && scoped.request + ? requestPage(scoped.request) + : Promise.resolve(); + return { + sessions: scoped.sessions, + query: scoped.query, + phase: scoped.phase, + nextOffset: scoped.nextOffset, + hasMore: scoped.hasMore, + search, + loadNext, + retry, + cancel, + }; +} diff --git a/src/modules/MobileRemote/app/useMobileTranscript.ts b/src/modules/MobileRemote/app/useMobileTranscript.ts index e9954fb0fb..2568a57a70 100644 --- a/src/modules/MobileRemote/app/useMobileTranscript.ts +++ b/src/modules/MobileRemote/app/useMobileTranscript.ts @@ -11,10 +11,10 @@ import { toMobileRpcError, } from "../connection/mobileRpcClient"; import type { MobileConnectionState } from "../connection/types"; +import { loadSubscriptionHistory } from "../lib/loadSubscriptionHistory"; import { type TranscriptRoundResult, type TranscriptSnapshotEnvelope, - type TranscriptSubscribeResult, applyLiveTranscriptSnapshot, applyTranscriptRoundResult, applyTranscriptSubscribeResult, @@ -42,6 +42,26 @@ export function useMobileTranscript({ activeSessionRef: MutableRefObject; connection: MobileConnectionState; }) { + const [openedSession, setOpenedSession] = useState<{ + requested: string; + sessionId: string; + managed: boolean; + } | null>(null); + const openingLeaseRef = useRef<{ + client: MobileRpcClient; + subscriptionId: string; + } | null>(null); + const releaseOpening = useCallback(() => { + const lease = openingLeaseRef.current; + openingLeaseRef.current = null; + if (!lease || lease.client !== clientRef.current) return false; + void lease.client + .call("session/unsubscribe", { + subscriptionId: lease.subscriptionId, + }) + .catch(() => undefined); + return true; + }, [clientRef]); const subscriptionGenerationRef = useRef(0); const roundRequestGenerationRef = useRef(0); const refreshInFlightRef = useRef<{ @@ -58,6 +78,7 @@ export function useMobileTranscript({ createInitialTranscriptLoadState ); const invalidateTranscriptRequests = useCallback(() => { + openingLeaseRef.current = null; subscriptionGenerationRef.current += 1; roundRequestGenerationRef.current += 1; refreshInFlightRef.current = null; @@ -70,6 +91,7 @@ export function useMobileTranscript({ }, []); const resetTranscript = useCallback(() => { invalidateTranscriptRequests(); + setOpenedSession(null); setTranscript(createInitialTranscriptLoadState()); }, [invalidateTranscriptRequests]); const showDemoTranscript = useCallback( @@ -97,40 +119,74 @@ export function useMobileTranscript({ async ( client: MobileRpcClient, sessionId: string, - subscriptionGeneration: number + subscriptionGeneration: number, + latestOnly = true ) => { + let resolvedId = sessionId; + const opening = connectionRef.current.capabilities?.sessionOpen === true; + const lease = opening + ? { client, subscriptionId: `open-${subscriptionGeneration}` } + : null; + if (lease) openingLeaseRef.current = lease; + const isCurrent = () => + clientRef.current === client && + activeSessionRef.current === resolvedId && + subscriptionGenerationRef.current === subscriptionGeneration; try { - const result = await client.call( - "session/subscribe", - { sessionId } - ); - if ( - clientRef.current !== client || - activeSessionRef.current !== sessionId || - subscriptionGenerationRef.current !== subscriptionGeneration - ) { - return; - } - setTranscript((prev) => - applyTranscriptSubscribeResult( - prev, - result, - sessionId, - subscriptionGeneration - ) + const applied = await loadSubscriptionHistory( + client, + sessionId, + isCurrent, + (result) => + setTranscript((prev) => + applyTranscriptSubscribeResult( + prev, + result, + resolvedId, + subscriptionGeneration + ) + ), + latestOnly, + lease + ? { + subscriptionId: lease.subscriptionId, + onIdentity: (canonical, managed) => { + resolvedId = canonical; + activeSessionRef.current = canonical; + setOpenedSession((previous) => ({ + requested: + previous?.sessionId === sessionId + ? previous.requested + : sessionId, + sessionId: canonical, + managed, + })); + setTranscript((prev) => ({ ...prev, sessionId: canonical })); + }, + } + : undefined ); - return true; + if (!applied && lease) + void client + .call("session/unsubscribe", { + subscriptionId: lease.subscriptionId, + }) + .catch(() => undefined); + return applied; } catch (error) { - if ( - clientRef.current !== client || - activeSessionRef.current !== sessionId || - subscriptionGenerationRef.current !== subscriptionGeneration - ) - return; + // This close queues behind an opening that timed out on the phone. + // Its lease prevents a late close from removing a newer subscription. + if (lease) + void client + .call("session/unsubscribe", { + subscriptionId: lease.subscriptionId, + }) + .catch(() => undefined); + if (!isCurrent()) return; setTranscript((prev) => failTranscriptLoad( prev, - sessionId, + resolvedId, subscriptionGeneration, toMobileRpcError(error).message ) @@ -138,7 +194,7 @@ export function useMobileTranscript({ throw error; } }, - [activeSessionRef, clientRef] + [activeSessionRef, clientRef, connectionRef] ); refreshSubscribedSessionRef.current = (sessionId: string) => { @@ -160,7 +216,12 @@ export function useMobileTranscript({ setTranscript((current) => beginTranscriptLoad(current, sessionId, subscriptionGeneration) ); - void requestSessionSnapshot(client, sessionId, subscriptionGeneration) + void requestSessionSnapshot( + client, + sessionId, + subscriptionGeneration, + false + ) .catch(() => undefined) .finally(() => { if (refreshInFlightRef.current?.token !== token) return; @@ -284,6 +345,9 @@ export function useMobileTranscript({ ]); return { + openingClient: openingLeaseRef.current?.client, + openedSession, + releaseOpening, transcript, setTranscript, transcriptView: getSelectedTranscriptView(transcript), diff --git a/src/modules/MobileRemote/auth/MobileAuthScreen.tsx b/src/modules/MobileRemote/auth/MobileAuthScreen.tsx index 226c1a7a66..73e91917fa 100644 --- a/src/modules/MobileRemote/auth/MobileAuthScreen.tsx +++ b/src/modules/MobileRemote/auth/MobileAuthScreen.tsx @@ -36,14 +36,14 @@ export function MobileAuthScreen({ return ( -
-
+
+
ORG2
-
+
{loading ? (
-

+

{t("auth.title")}

-

+

{t("auth.subtitle")}

diff --git a/src/modules/MobileRemote/auth/useMobileAccountActions.ts b/src/modules/MobileRemote/auth/useMobileAccountActions.ts new file mode 100644 index 0000000000..b2b8952383 --- /dev/null +++ b/src/modules/MobileRemote/auth/useMobileAccountActions.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "react"; + +import { ORG2_CLOUD_OFFICIAL_WEB_ORIGIN } from "@src/features/Org2Cloud/config"; + +import { useMobileRemotePlatform } from "../platform"; + +type AccountPage = "/account" | "/legal/privacy" | "/legal/terms"; + +/** Shared by Settings and Profile; external navigation never carries auth tokens. */ +export function useMobileAccountActions() { + const platform = useMobileRemotePlatform(); + const [state, setState] = useState<"idle" | "opening" | "failed">("idle"); + const attemptRef = useRef(null); + useEffect( + () => () => { + attemptRef.current = null; + }, + [] + ); + + const openCloudPage = async (path: AccountPage) => { + if (attemptRef.current) return; + const attempt = Symbol("open-account-page"); + attemptRef.current = attempt; + setState("opening"); + try { + await platform.openExternal( + new URL(path, ORG2_CLOUD_OFFICIAL_WEB_ORIGIN).href + ); + if (attemptRef.current === attempt) setState("idle"); + } catch { + if (attemptRef.current === attempt) setState("failed"); + } finally { + if (attemptRef.current === attempt) attemptRef.current = null; + } + }; + return { + opening: state === "opening", + openFailed: state === "failed", + openCloudPage, + }; +} diff --git a/src/modules/MobileRemote/components/MobileShell.test.ts b/src/modules/MobileRemote/components/MobileShell.test.ts index 013cb305b8..67064acb1e 100644 --- a/src/modules/MobileRemote/components/MobileShell.test.ts +++ b/src/modules/MobileRemote/components/MobileShell.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; @@ -16,6 +17,23 @@ describe("MobileShell", () => { expect(markup).toContain("h-full justify-center overflow-hidden"); expect(markup).toContain("pt-[env(safe-area-inset-top)]"); + expect(markup).toContain("pr-[env(safe-area-inset-right)]"); + expect(markup).toContain("pl-[env(safe-area-inset-left)]"); expect(markup).toContain("h-full min-h-0"); }); + + it("keeps the fluid shell width with a reading cap, not a phone-model width", () => { + const markup = renderToStaticMarkup(React.createElement(MobileShell)); + expect(markup).toContain("w-full"); + const styles = readFileSync( + new URL("../mobileChrome.scss", import.meta.url), + "utf8" + ); + const viewport = styles.match( + /\.mobile-shell__viewport\s*\{([^}]+)\}/ + )?.[1]; + expect(viewport).toContain("min-width: 0"); + expect(viewport).toContain("max-width: 48rem"); + expect(viewport).not.toContain("393px"); + }); }); diff --git a/src/modules/MobileRemote/components/MobileShell.tsx b/src/modules/MobileRemote/components/MobileShell.tsx index f4cb33fa26..c288dce55d 100644 --- a/src/modules/MobileRemote/components/MobileShell.tsx +++ b/src/modules/MobileRemote/components/MobileShell.tsx @@ -12,9 +12,9 @@ export interface MobileShellProps { */ export function MobileShell({ children, footer }: MobileShellProps) { return ( -
+
-
{children}
+
{children}
{footer}
diff --git a/src/modules/MobileRemote/components/MobileTopBar.test.ts b/src/modules/MobileRemote/components/MobileTopBar.test.ts new file mode 100644 index 0000000000..85b2dd3da1 --- /dev/null +++ b/src/modules/MobileRemote/components/MobileTopBar.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { MobileTopBar } from "./MobileTopBar"; + +describe("MobileTopBar", () => { + let host: HTMLDivElement; + let root: ReturnType; + const env = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + let previousAct: boolean | undefined; + + beforeEach(() => { + previousAct = env.IS_REACT_ACT_ENVIRONMENT; + env.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + env.IS_REACT_ACT_ENVIRONMENT = previousAct; + }); + + it("keeps the circular back control and title in their separate detail-header slots", () => { + const onBack = vi.fn(); + act(() => + root.render( + React.createElement(MobileTopBar, { + title: "A session title that is longer than the narrow phone header", + onBack, + backAriaLabel: "Return to sessions", + trailing: React.createElement("span", null, "Actions"), + }) + ) + ); + const header = host.querySelector("header")!; + const back = host.querySelector("button")!; + const title = host.querySelector("h1")!; + expect(header.classList.contains("mobile-top-bar--detail")).toBe(true); + expect(Array.from(header.children)).toEqual([ + back, + title, + header.querySelector(".mobile-top-bar__trailing"), + ]); + expect(title.classList.contains("mobile-top-bar__title")).toBe(true); + expect(title.textContent).toBe( + "A session title that is longer than the narrow phone header" + ); + // This asserts the DOM/style contract with the real shared Button; + // pixel alignment and actual clipping still require a browser check. + expect(back.classList.contains("button")).toBe(true); + expect(back.type).toBe("button"); + expect(back.getAttribute("aria-label")).toBe("Return to sessions"); + expect(back.style.width).toBe("var(--mobile-touch-size)"); + expect(back.style.height).toBe("var(--mobile-touch-size)"); + expect(back.style.borderRadius).toBe("50%"); + expect(back.style.padding).toBe("0px"); + expect(back.querySelector(".truncate")).toBeNull(); + expect(back.querySelector("svg")).not.toBeNull(); + act(() => back.click()); + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/MobileRemote/components/MobileTopBar.tsx b/src/modules/MobileRemote/components/MobileTopBar.tsx index 141d0fe164..7c6c72444b 100644 --- a/src/modules/MobileRemote/components/MobileTopBar.tsx +++ b/src/modules/MobileRemote/components/MobileTopBar.tsx @@ -27,6 +27,7 @@ export function MobileTopBar({ htmlType="button" size="mini" variant="tertiary" + shape="circle" className="mobile-chrome-icon-button" aria-label={backAriaLabel} onClick={onBack} diff --git a/src/modules/MobileRemote/components/PendingInboxList.tsx b/src/modules/MobileRemote/components/PendingInboxList.tsx new file mode 100644 index 0000000000..1341d16afc --- /dev/null +++ b/src/modules/MobileRemote/components/PendingInboxList.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { Placeholder } from "@src/components/Placeholder"; +import { HugeiconsIcon, Notification01Icon } from "@src/icons"; + +import type { MobilePendingInbox } from "../app/useMobilePendingInbox"; +import type { MobileSessionRow } from "../connection/types"; + +export function PendingInboxList({ + inbox, + sessions, + desktopName, + online, + onSelectSession, + onFocusPermission, +}: { + inbox: MobilePendingInbox; + sessions: MobileSessionRow[]; + desktopName: string; + online: boolean; + onSelectSession?: (id: string) => void; + onFocusPermission?: (requestId: string) => void; +}) { + const { t, i18n } = useTranslation("mobileRemote"); + return ( +
+ {!online ?

{t("inbox.offline")}

: null} + {inbox.phase === "syncing" ? ( +

{t("inbox.syncing")}

+ ) : null} + {inbox.phase === "error" ? ( +
+

{t("inbox.error")}

+ +
+ ) : null} + {inbox.phase === "ready" && !inbox.complete ? ( +

{t("inbox.incomplete")}

+ ) : null} + {inbox.phase === "ready" && inbox.complete && inbox.items.length === 0 ? ( + } + /> + ) : null} + {inbox.items.map((request) => { + const row = sessions.find( + (session) => session.id === request.sessionId + ); + const detail = ["command", "path", "description"] + .map((key) => request.toolArgs[key]) + .find((value) => typeof value === "string") as string | undefined; + const date = new Date(request.createdAtMs); + return ( + + ); + })} +
+ ); +} diff --git a/src/modules/MobileRemote/components/SessionDeviceTabs.test.ts b/src/modules/MobileRemote/components/SessionDeviceTabs.test.ts new file mode 100644 index 0000000000..53ba3b3a94 --- /dev/null +++ b/src/modules/MobileRemote/components/SessionDeviceTabs.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SessionDeviceTabs, + type SessionDeviceTabsProps, +} from "./SessionDeviceTabs"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/icons", () => ({ + HugeiconsIcon: () => null, + LaptopIcon: {}, +})); + +describe("SessionDeviceTabs", () => { + let container: HTMLDivElement; + let root: Root; + const onSelect = vi.fn(); + const props: SessionDeviceTabsProps = { + items: [ + { id: "macbook", name: "MacBook Pro · Alice", presence: "online" }, + { id: "studio", name: "Mac Studio · Bob", presence: "unknown" }, + ], + currentId: "macbook", + onSelect, + }; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onSelect.mockClear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + function render(overrides: Partial = {}) { + act(() => + root.render(createElement(SessionDeviceTabs, { ...props, ...overrides })) + ); + return Array.from(container.querySelectorAll("button")); + } + + it("reuses controlled device tabs without adding an unsupported aggregate", () => { + const tabs = render(); + expect(tabs).toHaveLength(2); + expect(tabs[0].dataset.active).toBe("true"); + expect(tabs[0].textContent).toContain("devices.currentDesktop"); + expect(tabs[0].textContent).toContain("devices.online"); + expect(tabs[1].textContent).toContain("devices.presenceUnknown"); + expect(tabs[1].querySelector(".bg-success-6")).toBeNull(); + expect( + container.querySelector('[role="group"]')?.getAttribute("aria-label") + ).toBe("devices.pairedDesktops"); + }); + + it("requests another device once and waits for the authoritative selection", () => { + const tabs = render(); + act(() => tabs[0].click()); + expect(onSelect).not.toHaveBeenCalled(); + act(() => tabs[1].click()); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith("studio"); + expect(tabs[0].dataset.active).toBe("true"); + const selectedTabs = render({ currentId: "studio" }); + expect(selectedTabs[0].dataset.active).toBe("false"); + expect(selectedTabs[1].dataset.active).toBe("true"); + }); + + it("blocks duplicate switches while pending and permits retry after failure", () => { + const pendingTabs = render({ disabled: true }); + expect(pendingTabs.every((tab) => tab.disabled)).toBe(true); + act(() => pendingTabs[1].click()); + expect(onSelect).not.toHaveBeenCalled(); + const restoredTabs = render(); + act(() => restoredTabs[1].click()); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith("studio"); + }); + + it("respects item availability without treating unknown presence as unavailable", () => { + const tabs = render({ + items: [ + ...props.items, + { + id: "removed", + name: "Unavailable", + presence: "offline", + disabled: true, + }, + ], + }); + expect(tabs[1].disabled).toBe(false); + expect(tabs[2].disabled).toBe(true); + act(() => tabs[2].click()); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it.each([ + "MacBook Pro · A very long workstation owner name", + "研发工作站 · 团队共享设备 · " + "长名称".repeat(32), + ])( + "keeps a full single-line device name in the scrolling strip: %s", + (name) => { + const tabs = render({ + items: [{ id: "mac", name, presence: "offline" }], + currentId: null, + }); + expect(tabs[0].textContent).toContain(name); + expect(tabs[0].dataset.active).toBe("false"); + // Exercise the real TabPill. A capped button or clipped label cannot be + // recovered by scrolling the outer strip, even with the full DOM text. + const list = container.querySelector( + ".mobile-session-device-tabs__list" + )!; + expect(list.className).not.toMatch( + /max-w-|\[&_button[^\]]*\]:overflow-hidden/ + ); + expect(tabs[0].classList.contains("shrink-0")).toBe(true); + expect(tabs[0].classList.contains("whitespace-nowrap")).toBe(true); + const label = tabs[0].querySelector(".grid")!; + expect(label.classList.contains("min-w-0")).toBe(false); + expect(label.classList.contains("overflow-hidden")).toBe(false); + expect(label.querySelector('[aria-hidden="true"]')?.textContent).toBe( + name + ); + expect(tabs[0].textContent).toContain("devices.offline"); + expect(container.querySelector(".overflow-x-auto")).not.toBeNull(); + } + ); + + it("does not invent a device when inventory is empty", () => { + render({ items: [] }); + expect(container.innerHTML).toBe(""); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/MobileRemote/components/SessionDeviceTabs.tsx b/src/modules/MobileRemote/components/SessionDeviceTabs.tsx new file mode 100644 index 0000000000..03b0e4ce1a --- /dev/null +++ b/src/modules/MobileRemote/components/SessionDeviceTabs.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import StatusDot from "@src/components/StatusDot"; +import TabPill from "@src/components/TabPill"; +import { HugeiconsIcon, LaptopIcon } from "@src/icons"; + +import type { DesktopPresence } from "../connection/types"; + +export interface SessionDeviceTabItem { + id: string; + name: string; + presence: DesktopPresence; + disabled?: boolean; +} + +export interface SessionDeviceTabsProps { + items: readonly SessionDeviceTabItem[]; + currentId: string | null; + onSelect: (id: string) => void; + disabled?: boolean; +} + +/** Selection and connection work belong to the provider, never to this strip. */ +export function SessionDeviceTabs({ + items, + currentId, + onSelect, + disabled = false, +}: SessionDeviceTabsProps) { + const { t } = useTranslation("mobileRemote"); + + if (items.length === 0) return null; + + return ( +
+ {/* Keep each name intrinsic-width; the outer strip owns horizontal overflow. */} + ({ + key: item.id, + label: item.name, + disabled: disabled || item.disabled, + dataTestId: "mobile-session-device-tab", + icon: ( + + + {t( + item.presence === "online" + ? "devices.online" + : item.presence === "offline" + ? "devices.offline" + : "devices.presenceUnknown" + )} + + } + /> +
+ ); +} + +SessionDeviceTabs.displayName = "SessionDeviceTabs"; diff --git a/src/modules/MobileRemote/components/SessionListItem.test.ts b/src/modules/MobileRemote/components/SessionListItem.test.ts index ea3e7f2425..13dafda75a 100644 --- a/src/modules/MobileRemote/components/SessionListItem.test.ts +++ b/src/modules/MobileRemote/components/SessionListItem.test.ts @@ -3,15 +3,29 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { SessionListItem } from "./SessionListItem"; +import { SESSION_STATUS_DOT_COLOR } from "@src/util/session/sessionStatusDot"; -function renderRow(status: "running" | "idle" = "idle"): HTMLButtonElement { +import { SessionListItem, type SessionListItemProps } from "./SessionListItem"; + +function renderRow( + status: SessionListItemProps["status"] = "idle", + metadata: Pick< + SessionListItemProps, + | "workspaceName" + | "updatedAtMs" + | "display" + | "visited" + | "mergeStatus" + | "compact" + > = {} +): HTMLButtonElement { const host = document.createElement("div"); host.innerHTML = renderToStaticMarkup( createElement(SessionListItem, { sessionId: "sdeagent-thread-1", name: "A very long session title that must remain inside its own row", status, + ...metadata, }) ); const row = host.querySelector( @@ -22,28 +36,133 @@ function renderRow(status: "running" | "idle" = "idle"): HTMLButtonElement { } describe("SessionListItem layout", () => { - it("reuses the compact Desktop sidebar row geometry and typography", () => { + it("uses a single aligned heading without a reserved metadata row in compact mode", () => { + const row = renderRow("idle", { + compact: true, + workspaceName: "Project", + updatedAtMs: 1000, + }); + expect(row.classList.contains("mobile-session-row--compact")).toBe(true); + expect(row.classList.contains("min-h-16")).toBe(false); + expect(row.querySelector(".mobile-session-row__meta")).toBeNull(); + expect(row.querySelector(".mobile-session-row__workspace")).toBeNull(); + const heading = row.querySelector(".mobile-session-row__heading")!; + expect(row.querySelector("time")).toBeNull(); + expect(heading.lastElementChild?.hasAttribute("data-session-status")).toBe( + true + ); + expect( + renderRow("unknown", { compact: true, updatedAtMs: NaN }).querySelector( + "time" + ) + ).toBeNull(); + }); + it("shares Desktop's terminal unread rule without inventing unread before synchronization", () => { + const green = (row: HTMLElement) => + row.innerHTML.includes(SESSION_STATUS_DOT_COLOR.unread); + expect(green(renderRow("completed", { visited: false }))).toBe(true); + expect(green(renderRow("completed", { visited: true }))).toBe(false); + expect(green(renderRow("completed"))).toBe(false); + expect( + green(renderRow("completed", { visited: false, mergeStatus: "pending" })) + ).toBe(false); + expect(green(renderRow("running", { visited: false }))).toBe(false); + expect(green(renderRow("waiting_for_user", { visited: false }))).toBe( + false + ); + expect(green(renderRow("offline", { visited: false }))).toBe(false); + }); + it("renders the wire agent metadata instead of guessing solely from the session ID", () => { + const original = renderRow().querySelector( + "[data-icon=session-sdeagent-thread-1]" + )!.innerHTML; + const managed = renderRow("idle", { + display: { cliAgentType: "claude_code" }, + }); + expect( + managed.querySelector("[data-icon=session-sdeagent-thread-1]")!.innerHTML + ).not.toBe(original); + }); + it("keeps the full title accessible while placing the dot in the title line", () => { const row = renderRow(); expect(row.tagName).toBe("BUTTON"); expect(row.className).toContain("flex"); - expect(row.className).toContain("h-8"); + expect(row.className).toContain("mobile-session-row"); + expect(row.className).toContain("min-h-16"); expect(row.className).toContain("w-full"); expect(row.children).toHaveLength(1); expect(row.querySelector("[data-icon=session-sdeagent-thread-1]")).not.toBe( null ); - expect(row.querySelector(".text-\\[13px\\]")?.textContent).toContain( - "A very long session title" - ); - expect(row.textContent).not.toContain("idle"); + expect( + row.querySelector(".mobile-session-row__title")?.textContent + ).toContain("A very long session title"); + expect( + row.querySelector(".mobile-session-row__title")?.getAttribute("title") + ).toBe("A very long session title that must remain inside its own row"); + expect(row.textContent).not.toContain("sessions.state.idle"); expect(row.textContent).not.toContain("LIVE"); }); + it("keeps workspace and date on the metadata line, separate from title and status", () => { + const row = renderRow("idle", { + workspaceName: "Long workspace", + updatedAtMs: 1_700_000_000_000, + }); + const meta = row.querySelector(".mobile-session-row__meta")!; + expect( + meta.querySelector(".mobile-session-row__workspace")?.textContent + ).toBe("Long workspace"); + expect(meta.querySelector("time")?.dateTime).toBe( + new Date(1_700_000_000_000).toISOString() + ); + expect(meta.querySelector("[data-session-status]")).toBeNull(); + expect(row.querySelector(".mobile-session-row__heading time")).toBeNull(); + }); + + it("does not invent workspace or date for missing or invalid metadata", () => { + const row = renderRow("unknown", { updatedAtMs: Number.NaN }); + expect(row.querySelector("time")).toBeNull(); + expect(row.querySelector(".mobile-session-row__workspace")).toBeNull(); + expect(row.querySelector('[data-session-status="unknown"]')).not.toBeNull(); + }); + it("shows the same compact working status dot used by Desktop rows", () => { const row = renderRow("running"); - expect(row.querySelector('[aria-label="Working"]')).not.toBe(null); - expect(row.textContent).not.toContain("running"); + expect(row.querySelector('[aria-label="sessions.state.running"]')).not.toBe( + null + ); + expect(row.textContent).not.toContain("sessions.state.running"); }); + + it.each([ + ["running", "working"], + ["awaiting_approval", "asking"], + ["waiting_for_user", "asking"], + ["waiting_for_funds", "working"], + ["paused", "default"], + ["completed", "default"], + ["idle", "default"], + ["offline", "default"], + ["unknown", "default"], + ] as const)( + "uses Desktop's %s tone on one trailing dot, without status text", + (status, tone) => { + const row = renderRow(status); + const marker = row.querySelector(`[data-session-status="${status}"]`)!; + expect( + row.querySelector(".mobile-session-row__heading")?.lastElementChild + ).toBe(marker); + expect(marker.querySelector("span")?.getAttribute("style")).toContain( + SESSION_STATUS_DOT_COLOR[tone] + ); + expect( + row.querySelectorAll(`[aria-label="sessions.state.${status}"]`) + ).toHaveLength(1); + expect(row.textContent).not.toContain(`sessions.state.${status}`); + expect(row.querySelector(".mobile-session-row__status")).toBeNull(); + } + ); }); diff --git a/src/modules/MobileRemote/components/SessionListItem.tsx b/src/modules/MobileRemote/components/SessionListItem.tsx index e47d9a1486..5299e7a650 100644 --- a/src/modules/MobileRemote/components/SessionListItem.tsx +++ b/src/modules/MobileRemote/components/SessionListItem.tsx @@ -1,16 +1,34 @@ import React from "react"; +import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import { SESSION_ROW_PRESENTATION, SessionRowLeadingIcon, + SessionRowStatusDot, } from "@src/components/SessionRowPresentation"; +import { isSessionInProgress } from "@src/util/session/sessionInProgress"; import { resolveSessionRowIcon } from "@src/util/session/sessionSidebarRow"; +import { isSessionCompletedUnread } from "@src/util/session/sessionStatusDot"; + +import type { MobileSessionDisplay } from "../connection/types"; +import { + type MobileRowStatus, + mobileSessionIconInput, +} from "./sessionRowPresentation"; export interface SessionListItemProps { sessionId: string; name: string; - status: "running" | "idle"; + status: MobileRowStatus; + display?: MobileSessionDisplay; + /** Undefined means unsynchronized/unsupported, never assume unread. */ + visited?: boolean; + mergeStatus?: string | null; + workspaceName?: string | null; + /** Workspace is already conveyed by the section heading. */ + compact?: boolean; + updatedAtMs?: number; onSelect?: () => void; } @@ -18,9 +36,44 @@ export function SessionListItem({ sessionId, name, status, + display, + visited, + mergeStatus, + workspaceName, + compact = false, + updatedAtMs, onSelect, }: SessionListItemProps) { - const statusTone = status === "running" ? "working" : "default"; + const { t, i18n } = useTranslation("mobileRemote"); + const statusTone = + status === "awaiting_approval" || status === "waiting_for_user" + ? "asking" + : isSessionInProgress(status) + ? "working" + : visited === false && + isSessionCompletedUnread( + { session_id: sessionId, status, mergeStatus }, + new Set() + ) + ? "unread" + : "default"; + const statusLabel = + statusTone === "unread" + ? t("sessions.state.unread") + : t(`sessions.state.${status}`); + const updated = updatedAtMs == null ? null : new Date(updatedAtMs); + const timestamp = + updated && !Number.isNaN(updated.getTime()) ? ( + + ) : null; return ( diff --git a/src/modules/MobileRemote/components/SessionViewMenu.test.ts b/src/modules/MobileRemote/components/SessionViewMenu.test.ts new file mode 100644 index 0000000000..9df2f0439f --- /dev/null +++ b/src/modules/MobileRemote/components/SessionViewMenu.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import React, { act, useState } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DROPDOWN_CLASSES, + DROPDOWN_ITEM, + DROPDOWN_PANEL, +} from "@src/components/Dropdown/tokens"; + +import { SessionViewMenu } from "./SessionViewMenu"; +import type { SessionGroupBy } from "./sessionGrouping"; + +vi.mock("../platform", async () => { + const { createBrowserMobileRemotePlatform } = + await import("../platform/browser"); + const platform = createBrowserMobileRemotePlatform(); + return { useMobileRemotePlatform: () => platform }; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("SessionViewMenu", () => { + let host: HTMLDivElement; + let root: Root; + const change = vi.fn(); + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + change.mockClear(); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + function Harness() { + const [groupBy, setGroupBy] = useState("none"); + return React.createElement(SessionViewMenu, { + value: groupBy, + onChange: (value) => { + change(value); + setGroupBy(value); + }, + }); + } + act(() => root.render(React.createElement(Harness))); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + }); + + const trigger = () => host.querySelector("button")!; + const option = (id: string) => + document.querySelector(`[data-testid=mobile-group-${id}]`)!; + const click = async (element: HTMLElement) => + act(async () => { + element.click(); + await Promise.resolve(); + }); + + it("keeps the ellipsis in a circular touch-sized shared Button without a label wrapper", async () => { + const control = trigger(); + // Real Button inline styles win over mobile CSS. Do not replace this + // component with a mock that omits its desktop sizing or label wrapper. + expect(control.classList.contains("button")).toBe(true); + expect(control.type).toBe("button"); + expect(control.style.width).toBe("var(--mobile-touch-size)"); + expect(control.style.height).toBe("var(--mobile-touch-size)"); + expect(control.style.padding).toBe("0px"); + expect(control.style.borderRadius).toBe("50%"); + expect(control.querySelector(".truncate")).toBeNull(); + expect(control.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(control.textContent).toBe(""); + expect(control.getAttribute("aria-label")).toBe("sessions.viewOptions"); + expect(control.getAttribute("aria-haspopup")).toBe("listbox"); + expect(control.getAttribute("aria-expanded")).toBe("false"); + await click(control); + expect(control.getAttribute("aria-expanded")).toBe("true"); + await click(option("time")); + expect(change).toHaveBeenCalledWith("time"); + expect(control.getAttribute("aria-expanded")).toBe("false"); + }); + + it("opens Group by, changes the controlled selection, closes, and can return to none", async () => { + expect(trigger().getAttribute("aria-label")).toBe("sessions.viewOptions"); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + await click(trigger()); + const content = document.querySelector( + '[data-testid="mobile-session-view-menu-panel"]' + )!; + const panel = content.parentElement!; + expect(content).not.toBeNull(); + expect(panel).not.toBeNull(); + expect(panel.classList.contains("mobile-session-view-menu")).toBe(true); + expect(host.contains(panel)).toBe(false); + expect(panel.classList.contains(DROPDOWN_PANEL.bgClass)).toBe(true); + expect(panel.classList.contains(DROPDOWN_PANEL.menuMinWidthClass)).toBe( + true + ); + expect(panel.classList.contains(DROPDOWN_PANEL.borderRadiusClass)).toBe( + true + ); + expect(panel.classList.contains(DROPDOWN_PANEL.shadowClass)).toBe(true); + expect(content.querySelector(".shadow-dropdown")).toBeNull(); + expect(document.body.textContent).toContain("sessions.groupBy"); + const heading = Array.from(panel.querySelectorAll("span")).find( + (element) => element.textContent === "sessions.groupBy" + ); + expect(heading?.classList.contains("whitespace-nowrap")).toBe(true); + expect(heading?.parentElement?.className).toBe( + DROPDOWN_CLASSES.sectionLabel + ); + expect(option("none").getAttribute("aria-selected")).toBe("true"); + expect( + option("none").querySelector('svg[aria-hidden="true"]') + ).not.toBeNull(); + expect( + option("time").querySelector('svg[aria-hidden="true"]') + ).not.toBeNull(); + expect( + option("time") + .querySelector('svg[aria-hidden="true"]') + ?.getAttribute("width") + ).toBe(String(DROPDOWN_ITEM.iconSize)); + expect( + option("time") + .querySelector('svg[aria-hidden="true"]') + ?.getAttribute("height") + ).toBe(String(DROPDOWN_ITEM.iconSize)); + const noneChildren = Array.from(option("none").children); + expect( + noneChildren[0]?.querySelector('svg[aria-hidden="true"]') + ).not.toBeNull(); + expect(noneChildren[1]?.textContent).toContain("sessions.groupNone"); + await click(option("time")); + expect(change).toHaveBeenCalledTimes(1); + expect(change).toHaveBeenCalledWith("time"); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + await click(trigger()); + expect(option("time").getAttribute("aria-selected")).toBe("true"); + expect( + option("workspace").querySelector('svg[aria-hidden="true"]') + ).not.toBeNull(); + await click(option("workspace")); + expect(change).toHaveBeenLastCalledWith("workspace"); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + await click(trigger()); + expect(option("workspace").getAttribute("aria-selected")).toBe("true"); + await click(option("none")); + expect(change).toHaveBeenLastCalledWith("none"); + }); + + it("supports keyboard open and Escape without changing grouping", async () => { + trigger().focus(); + await act(async () => { + trigger().dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }) + ); + }); + expect(trigger().getAttribute("aria-expanded")).toBe("true"); + await act(async () => { + trigger().dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }) + ); + }); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(trigger()); + expect(change).not.toHaveBeenCalled(); + }); + + it("closes the surfaced menu on outside click without changing selection", async () => { + await click(trigger()); + await act(async () => { + document.body.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }) + ); + document.body.click(); + }); + expect(trigger().getAttribute("aria-expanded")).toBe("false"); + expect( + document.querySelector('[data-testid="mobile-session-view-menu-panel"]') + ).toBeNull(); + expect(change).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/MobileRemote/components/SessionViewMenu.tsx b/src/modules/MobileRemote/components/SessionViewMenu.tsx new file mode 100644 index 0000000000..7d94773762 --- /dev/null +++ b/src/modules/MobileRemote/components/SessionViewMenu.tsx @@ -0,0 +1,124 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Dropdown from "@src/components/Dropdown"; +import { + DROPDOWN_CLASSES, + DROPDOWN_ITEM, + DROPDOWN_PANEL, +} from "@src/components/Dropdown/tokens"; +import { + Clock01Icon, + FolderClosedIcon, + HugeiconsIcon, + ListIcon, + MoreHorizontalIcon, +} from "@src/icons"; + +import { useMobileRemotePlatform } from "../platform"; +import type { SessionGroupBy } from "./sessionGrouping"; +import "./sessionViewMenu.scss"; + +export interface SessionViewMenuProps { + value: SessionGroupBy; + onChange: (value: SessionGroupBy) => void; +} + +export function SessionViewMenu({ value, onChange }: SessionViewMenuProps) { + const { t } = useTranslation("mobileRemote"); + const [open, setOpen] = useState(false); + const { runtime } = useMobileRemotePlatform(); + const popupContainer = runtime.portalContainer?.(); + + return ( + popupContainer as HTMLElement : undefined + } + avoidViewportOverflow + popupVisible={open} + onVisibleChange={setOpen} + value={value} + className={`mobile-session-view-menu ${DROPDOWN_PANEL.menuMinWidthClass}`} + dropdownRender={(menu) => ( +
+
+ {t("sessions.groupBy")} +
+ {menu} +
+ )} + options={[ + { + label: t("sessions.groupNone"), + value: "none", + icon: ( +
+ ); +} diff --git a/src/modules/MobileRemote/components/changes/MobileChangeReview.panel.test.ts b/src/modules/MobileRemote/components/changes/MobileChangeReview.panel.test.ts new file mode 100644 index 0000000000..9b8923ff7e --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeReview.panel.test.ts @@ -0,0 +1,714 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import type { MobileRpcClient } from "../../connection/mobileRpcClient"; +import { MobileChangeReview } from "./MobileChangeReview"; +import type { ChangeFile } from "./useChangeReview"; + +const copy = vi.hoisted(() => vi.fn()); +vi.mock("../../platform", async () => { + const { createBrowserMobileRemotePlatform } = + await import("../../platform/browser"); + const platform = createBrowserMobileRemotePlatform(); + return { useMobileRemotePlatform: () => platform }; +}); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => + options?.count === undefined ? key : `${key} (${options.count})`, + }), +})); +vi.mock("@src/scaffold/ModalSystem", () => ({ + default: ({ + children, + title, + headerActions, + onClose, + closable, + className, + }: React.PropsWithChildren<{ + title: React.ReactNode; + headerActions?: React.ReactNode; + onClose: () => void; + closable?: boolean; + className?: string; + }>) => + React.createElement( + "div", + { role: "dialog", className }, + title, + headerActions, + children, + closable !== false && + React.createElement("button", { onClick: onClose }, "close") + ), +})); +vi.mock("../transcript/useMobileCopyText", () => ({ + useMobileCopyText: () => ({ state: "idle", copy }), +})); +vi.mock("../transcript/MobileReadonlyEditor", () => ({ + default: ({ + content, + wrap, + height, + }: { + content: string; + wrap: boolean; + height?: string; + }) => + React.createElement( + "pre", + { "data-wrap": String(wrap), "data-height": height }, + content + ), +})); + +let root: ReturnType; +let host: HTMLDivElement; +let intersectImmediately: boolean; +let observers: { callback: (entries: unknown[]) => void; active: boolean }[]; +const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView" +); +const firstFile: ChangeFile = { + path: "src/a.ts", + additions: 3, + deletions: 1, + patches: [], + before: "before a", + after: "after a", + availability: "snapshots", +}; +const secondFile = { ...firstFile, path: "src/b.ts", after: "after b" }; +const review = (files: ChangeFile[] = [firstFile]) => ({ + complete: true, + files, +}); + +it("projects lazy patch responses into real numbered rows and expands long patches without another request", async () => { + const patch = `--- /dev/null\n+++ b/src/a.ts\n@@ -0,0 +1,100 @@\n${Array.from({ length: 100 }, (_, i) => `+const value${i + 1} = ${i + 1};`).join("\n")}\n`; + const changed: ChangeFile = { + ...firstFile, + before: null, + after: null, + patches: [patch], + availability: "patch_only", + }; + const call = vi.fn(async (_method, params) => + review([{ ...changed, patches: params.filePath ? [patch] : [] }]) + ); + await render(propsFor(call)); + await tick(); + await open(); + await tick(); + expect(panel().querySelectorAll(".mobile-patch-diff__line")).toHaveLength(80); + expect(panel().querySelector(".mobile-patch-diff__number")?.textContent).toBe( + "1" + ); + expect(panel().textContent).not.toContain("/dev/null"); + expect(panel().textContent).not.toContain("@@"); + const requests = call.mock.calls.length; + await act(async () => + panel() + .querySelector(".mobile-patch-diff__more")! + .click() + ); + expect(panel().querySelectorAll(".mobile-patch-diff__line")).toHaveLength( + 100 + ); + expect(call).toHaveBeenCalledTimes(requests); + await act(async () => panelButton("changeReview.full: src/a.ts").click()); + await tick(); + expect(panel().textContent).toContain("changeReview.unavailable"); +}); +const deferred = () => { + let resolve!: (value: unknown) => void; + const promise = new Promise((finish) => { + resolve = finish; + }); + return { promise, resolve }; +}; +beforeEach(() => { + vi.useFakeTimers(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); + copy.mockClear(); + intersectImmediately = true; + observers = []; + vi.stubGlobal( + "IntersectionObserver", + class { + private record; + constructor(callback: (entries: unknown[]) => void) { + this.record = { callback, active: false }; + observers.push(this.record); + } + observe() { + this.record.active = true; + if (intersectImmediately) + this.record.callback([{ isIntersecting: true }]); + } + disconnect() { + this.record.active = false; + } + } + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + if (originalScrollIntoView) + Object.defineProperty( + HTMLElement.prototype, + "scrollIntoView", + originalScrollIntoView + ); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; +}); +const panel = () => host.querySelector('[role="dialog"]')!; +const panelButton = (label: string) => + Array.from(panel().querySelectorAll("button")).find( + (button) => + button.getAttribute("aria-label") === label || + button.textContent === label + )!; +const tick = async () => { + await act(async () => vi.advanceTimersByTimeAsync(300)); +}; +const propsFor = (call: ReturnType) => ({ + client: { call } as unknown as MobileRpcClient, + sessionId: "panel", + roundId: "one", + online: true, + revision: "1", +}); +const render = async (props: ReturnType) => { + await act(async () => + root.render(React.createElement(MobileChangeReview, props)) + ); +}; +const open = async () => { + await act(async () => + host + .querySelector(".mobile-change-review__heading")! + .click() + ); + await tick(); +}; +const scope = async (value: "turn" | "session" | "workspace") => { + await act(async () => { + panel() + .querySelector(".mobile-change-review__scope button")! + .click(); + }); + await act(async () => { + Array.from(document.querySelectorAll('[role="option"]')) + .find((option) => option.textContent?.includes(`changeReview.${value}`))! + .click(); + }); +}; +const mode = async (value: "diff" | "full") => { + await act(async () => + panel() + .querySelector( + `.mobile-change-review__mode [data-tab-key="${value}"]` + )! + .click() + ); +}; +const inViewport = async (visible: boolean) => { + await act(async () => + observers + .filter((observer) => observer.active) + .forEach((observer) => observer.callback([{ isIntersecting: visible }])) + ); +}; +const fileToggle = () => + panel().querySelector( + ".mobile-change-review__file-toggle" + )!; + +it("retries a failed panel manifest with a pending state and no duplicate turn request", async () => { + const retry = deferred(); + let sessionAttempts = 0; + const call = vi.fn().mockImplementation((_method, params) => { + if (params.scope === "session" && !params.filePath) { + sessionAttempts++; + return sessionAttempts === 1 + ? Promise.reject(new Error("private failure")) + : retry.promise; + } + return Promise.resolve(review()); + }); + await render(propsFor(call)); + await tick(); + await open(); + await scope("session"); + await tick(); + expect(panel().querySelector('[data-state="error"]')?.textContent).toContain( + "changeReview.loadFailed" + ); + expect(panel().textContent).not.toContain("private failure"); + expect( + panel().querySelector( + ".mobile-change-review__expand-all" + )?.disabled + ).toBe(true); + await act(async () => panelButton("changeReview.retry").click()); + expect(panel().querySelector('[data-state="error"]')).toBeNull(); + expect(panel().querySelector('[data-state="loading"]')).not.toBeNull(); + expect( + panel().querySelector( + ".mobile-change-review__expand-all" + )?.disabled + ).toBe(true); + await tick(); + expect(sessionAttempts).toBe(2); + await act(async () => retry.resolve(review())); + await tick(); + expect(panel().querySelector('[data-state="error"]')).toBeNull(); + expect(panel().querySelector("pre")?.textContent).toBe("after a"); + expect( + panel().querySelector( + ".mobile-change-review__expand-all" + )?.disabled + ).toBe(false); + expect( + call.mock.calls.filter( + ([, params]) => params.scope === "turn" && !params.filePath + ) + ).toHaveLength(1); +}); + +it("keeps the newer scope visible when an aborted old-scope response arrives", async () => { + const old = deferred(); + let oldSignal!: AbortSignal; + const call = vi.fn().mockImplementation((_method, params, signal) => { + if (params.scope === "session") { + oldSignal = signal; + return old.promise; + } + return Promise.resolve( + review( + params.scope === "workspace" + ? [{ ...firstFile, path: "workspace.ts" }] + : [firstFile] + ) + ); + }); + await render(propsFor(call)); + await tick(); + await open(); + await scope("session"); + await tick(); + await scope("workspace"); + await tick(); + expect(oldSignal.aborted).toBe(true); + expect(panel().textContent).toContain("workspace.ts"); + await act(async () => + old.resolve(review([{ ...firstFile, path: "stale-session.ts" }])) + ); + expect(panel().textContent).toContain("workspace.ts"); + expect(panel().textContent).not.toContain("stale-session.ts"); +}); + +it("shows the first available full file after a refreshed manifest removes the selected path", async () => { + let files = [firstFile, secondFile]; + const call = vi.fn().mockImplementation(() => Promise.resolve(review(files))); + const props = propsFor(call); + await render(props); + await tick(); + const row = host.querySelector( + '[data-mobile-change-path="src/b.ts"]' + )!; + await act(async () => row.click()); + await mode("full"); + await tick(); + expect(panel().querySelector("pre")?.textContent).toBe("after b"); + files = [ + { ...firstFile, path: "src/c.ts", after: "new file c" }, + { ...firstFile, path: "src/d.ts", after: "new file d" }, + ]; + await render({ ...props, revision: "2" }); + await tick(); + await tick(); + expect( + panel().querySelector(".mobile-change-review__file-heading")?.textContent + ).toContain("c.ts"); + expect(panel().querySelector("pre")?.textContent).toBe("new file c"); + expect( + panel().querySelector(".mobile-change-review__file-picker button") + ?.textContent + ).toBe("src/c.ts"); + expect(panel().textContent).not.toContain("b.ts"); +}); + +it("distinguishes unavailable snapshots, empty scope, and disconnected desktop", async () => { + const call = vi + .fn() + .mockImplementation((_method, params) => + Promise.resolve( + params.scope === "session" + ? review([]) + : review([{ ...firstFile, before: null, after: null }]) + ) + ); + const props = propsFor(call); + await render(props); + await tick(); + await open(); + await mode("full"); + await tick(); + expect(panel().querySelector('[data-state="unavailable"]')).not.toBeNull(); + expect(panel().querySelector('[data-state="empty"]')).toBeNull(); + await mode("diff"); + await scope("session"); + await tick(); + expect(panel().querySelector('[data-state="empty"]')).not.toBeNull(); + expect(panel().querySelector('[data-state="unavailable"]')).toBeNull(); + expect( + panel().querySelector( + ".mobile-change-review__expand-all" + )?.disabled + ).toBe(true); + await render({ ...props, online: false }); + const count = call.mock.calls.length; + await tick(); + expect(panel().querySelector('[data-state="offline"]')).not.toBeNull(); + expect(panel().querySelector('[data-state="empty"]')).toBeNull(); + expect( + panel().querySelector( + ".mobile-change-review__expand-all" + )?.disabled + ).toBe(true); + expect(call).toHaveBeenCalledTimes(count); +}); + +it("aborts panel-owned pending requests on close without destroying the inline turn manifest", async () => { + const pending = deferred(); + let scopedSignal!: AbortSignal; + const call = vi.fn().mockImplementation((_method, params, signal) => { + if (params.scope === "session") { + scopedSignal = signal; + return pending.promise; + } + return Promise.resolve(review()); + }); + await render(propsFor(call)); + await tick(); + await open(); + await scope("session"); + await tick(); + await act(async () => panelButton("common:actions.close").click()); + expect(scopedSignal.aborted).toBe(true); + expect(panel()).toBeNull(); + await act(async () => + pending.resolve(review([{ ...firstFile, path: "late.ts" }])) + ); + expect(host.textContent).toContain("a.ts"); + expect( + host.querySelector('[data-mobile-change-path="src/a.ts"]') + ).not.toBeNull(); + expect(host.textContent).not.toContain("late.ts"); + await open(); + expect(panel().textContent).toContain("a.ts"); + expect( + call.mock.calls.filter( + ([, params]) => params.scope === "turn" && !params.filePath + ) + ).toHaveLength(1); +}); + +it("uses segmented modes and real icon-only controls with mobile geometry", async () => { + const call = vi.fn().mockResolvedValue(review()); + await render(propsFor(call)); + await tick(); + await open(); + const modes = panel().querySelector(".mobile-change-review__mode")!; + expect(modes.tagName).not.toBe("SELECT"); + expect(modes.querySelectorAll("button")).toHaveLength(2); + expect(panel().querySelector("pre")?.dataset.height).toBe("auto"); + expect( + modes.querySelector('[data-tab-key="diff"]')?.getAttribute("data-active") + ).toBe("true"); + expect( + modes.querySelector('[data-tab-key="diff"] .sr-only')?.textContent + ).toBe("changeReview.selected"); + expect(modes.querySelector('[data-tab-key="full"] .sr-only')).toBeNull(); + const scopes = panel().querySelector( + ".mobile-change-review__scope button" + )!; + expect(scopes.textContent).toBe("changeReview.turn"); + expect(panel().querySelector("select")).toBeNull(); + await act(async () => scopes.click()); + expect(document.querySelectorAll('[role="option"]')).toHaveLength(3); + expect( + document + .querySelector(".mobile-change-review-menu") + ?.classList.contains("mobile-change-review-menu--scope") + ).toBe(true); + expect( + document.querySelector(".mobile-change-review-menu--files") + ).toBeNull(); + await act(async () => scopes.click()); + expect( + panel().querySelector( + ".mobile-change-review__file-actions" + )?.open + ).toBe(false); + const icons = [ + panelButton("common:actions.close"), + panelButton("changeReview.wrap"), + panelButton("changeReview.copy"), + panel().querySelector( + ".mobile-change-review__expand-all" + )!, + ]; + for (const icon of icons) { + expect(icon.classList.contains("button")).toBe(true); + expect(icon.querySelector("svg")).not.toBeNull(); + expect(icon.querySelector(".truncate")).toBeNull(); + expect(icon.style.width).toBe("var(--mobile-change-touch-size)"); + expect(icon.style.height).toBe("var(--mobile-change-touch-size)"); + expect(icon.style.padding).toBe("0px"); + } + expect(icons[0].style.borderRadius).toBe("50%"); + await act(async () => panelButton("changeReview.copy").click()); + expect(copy).toHaveBeenCalledTimes(1); + await mode("full"); + await tick(); + expect(panel().querySelector("pre")?.dataset.height).toBe("100%"); + expect( + modes.querySelector('[data-tab-key="full"]')?.getAttribute("data-active") + ).toBe("true"); + expect( + modes.querySelector('[data-tab-key="full"] .sr-only')?.textContent + ).toBe("changeReview.selected"); + expect(modes.querySelector('[data-tab-key="diff"] .sr-only')).toBeNull(); + expect(panel().querySelector(".mobile-change-review__expand-all")).toBeNull(); + await scope("session"); + expect(scopes.textContent).toBe("changeReview.session"); +}); + +it("uses the shared searchable file dropdown and disposes its portal when review closes", async () => { + const call = vi + .fn() + .mockImplementation((_method, params) => + Promise.resolve( + review( + [firstFile, secondFile].filter( + (file) => !params.filePath || params.filePath === file.path + ) + ) + ) + ); + await render(propsFor(call)); + await tick(); + await open(); + await mode("full"); + await tick(); + const trigger = panel().querySelector( + ".mobile-change-review__file-picker button" + )!; + expect(panel().querySelector("select")).toBeNull(); + const requests = call.mock.calls.length; + await act(async () => trigger.click()); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + const input = document.querySelector( + ".mobile-change-review-menu input" + )!; + expect(input).not.toBeNull(); + expect( + document.querySelector(".mobile-change-review-menu--files") + ).not.toBeNull(); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )!.set!.call(input, "b.ts"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(document.querySelectorAll('[role="option"]')).toHaveLength(1); + expect(call).toHaveBeenCalledTimes(requests); + await act(async () => + document.querySelector('[role="option"]')!.click() + ); + await tick(); + expect(panel().querySelector("pre")?.textContent).toBe("after b"); + expect(trigger.textContent).toBe("src/b.ts"); + expect(document.querySelector(".mobile-change-review-menu")).toBeNull(); + await act(async () => trigger.click()); + expect(document.querySelectorAll('[role="option"]')).toHaveLength(2); + const modalEscape = vi.fn(); + document.addEventListener("keydown", modalEscape); + await act(async () => + document + .querySelector(".mobile-change-review-menu input")! + .dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }) + ) + ); + document.removeEventListener("keydown", modalEscape); + expect(modalEscape).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(trigger); + expect(document.querySelector(".mobile-change-review-menu")).toBeNull(); + expect(panel()).not.toBeNull(); + await act(async () => trigger.click()); + await act(async () => panelButton("common:actions.close").click()); + expect(document.querySelector(".mobile-change-review-menu")).toBeNull(); +}); + +it("opens full content even after every diff file was collapsed", async () => { + const call = vi.fn().mockResolvedValue(review([firstFile, secondFile])); + await render(propsFor(call)); + await tick(); + await open(); + const expandAll = () => + panel().querySelector( + ".mobile-change-review__expand-all" + )!; + await act(async () => expandAll().click()); + await act(async () => expandAll().click()); + expect(fileToggle().getAttribute("aria-expanded")).toBe("false"); + await mode("full"); + await tick(); + expect(panel().querySelector("pre")?.textContent).toBe("after a"); + expect(fileToggle().disabled).toBe(true); +}); + +it("a repeated expand-all command supersedes an older manual file collapse", async () => { + const call = vi.fn().mockResolvedValue(review([firstFile, secondFile])); + await render(propsFor(call)); + await tick(); + await open(); + const expandAll = () => + panel().querySelector( + ".mobile-change-review__expand-all" + )!; + expect(expandAll().getAttribute("aria-label")).toBe("changeReview.expandAll"); + expect( + expandAll().querySelector('[data-icon="chevrons-up-down"]') + ).not.toBeNull(); + await act(async () => expandAll().click()); + expect(expandAll().getAttribute("aria-label")).toBe( + "changeReview.collapseAll" + ); + expect( + expandAll().querySelector('[data-icon="chevrons-down-up"]') + ).not.toBeNull(); + await act(async () => fileToggle().click()); + expect(fileToggle().getAttribute("aria-expanded")).toBe("false"); + await act(async () => expandAll().click()); + expect(expandAll().getAttribute("title")).toBe("changeReview.expandAll"); + expect( + expandAll().querySelector('[data-icon="chevrons-up-down"]') + ).not.toBeNull(); + await act(async () => expandAll().click()); + expect(fileToggle().getAttribute("aria-expanded")).toBe("true"); + expect( + Array.from( + panel().querySelectorAll(".mobile-change-review__file-toggle") + ).every((entry) => entry.getAttribute("aria-expanded") === "true") + ).toBe(true); +}); + +it("keeps detail work viewport-gated and aborts it when the file collapses", async () => { + intersectImmediately = false; + const pending = deferred(); + let detailSignal!: AbortSignal; + const call = vi.fn().mockImplementation((_method, params, signal) => { + if (params.filePath) { + detailSignal = signal; + return pending.promise; + } + return Promise.resolve(review()); + }); + await render(propsFor(call)); + await tick(); + await open(); + expect(call.mock.calls.filter(([, params]) => params.filePath)).toHaveLength( + 0 + ); + await inViewport(true); + await tick(); + expect(call.mock.calls.filter(([, params]) => params.filePath)).toHaveLength( + 1 + ); + await act(async () => fileToggle().click()); + expect(detailSignal.aborted).toBe(true); + await act(async () => pending.resolve(review())); + expect(panel().querySelector("pre")).toBeNull(); +}); + +it("falls back to an available after snapshot when a reloaded file no longer has its selected before side", async () => { + let currentFile = firstFile; + const call = vi + .fn() + .mockImplementation(() => Promise.resolve(review([currentFile]))); + await render(propsFor(call)); + await tick(); + await open(); + await mode("full"); + await tick(); + await act(async () => panelButton("changeReview.after").click()); + expect(panel().querySelector("pre")?.textContent).toBe("before a"); + await inViewport(false); + currentFile = { ...firstFile, before: null, after: "reloaded after" }; + await inViewport(true); + await tick(); + expect(panel().querySelector("pre")?.textContent).toBe("reloaded after"); + expect(panelButton("changeReview.after").disabled).toBe(true); + expect(panel().querySelector('[data-state="unavailable"]')).toBeNull(); +}); + +it("renders an empty-string full snapshot as valid content instead of an unavailable file", async () => { + const call = vi.fn().mockResolvedValue(review([{ ...firstFile, after: "" }])); + await render(propsFor(call)); + await tick(); + await open(); + await mode("full"); + await tick(); + expect(panel().querySelector("pre")).not.toBeNull(); + expect(panel().querySelector("pre")?.textContent).toBe(""); + expect(panel().querySelector('[data-state="unavailable"]')).toBeNull(); + expect(panelButton("changeReview.copy").disabled).toBe(false); + await act(async () => panelButton("changeReview.after").click()); + expect(panel().querySelector("pre")?.textContent).toBe("before a"); + await act(async () => panelButton("changeReview.before").click()); + expect(panel().querySelector("pre")?.textContent).toBe(""); +}); + +it("renders reconstructed history with provenance and allows both complete versions", async () => { + const call = vi + .fn() + .mockResolvedValue( + review([{ ...firstFile, availability: "reconstructed_from_history" }]) + ); + await render(propsFor(call)); + await tick(); + await open(); + await mode("full"); + await tick(); + expect(panel().textContent).toContain("changeReview.reconstructed"); + expect(panel().querySelector("pre")?.textContent).toBe(firstFile.after); + expect(panel().querySelector('[data-state="unavailable"]')).toBeNull(); + await act(async () => panelButton("changeReview.after").click()); + expect(panel().querySelector("pre")?.textContent).toBe(firstFile.before); + expect(panelButton("changeReview.copy").disabled).toBe(false); +}); diff --git a/src/modules/MobileRemote/components/changes/MobileChangeReview.test.ts b/src/modules/MobileRemote/components/changes/MobileChangeReview.test.ts new file mode 100644 index 0000000000..f170020419 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeReview.test.ts @@ -0,0 +1,694 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import type { MobileRpcClient } from "../../connection/mobileRpcClient"; +import { MobileChangeReview } from "./MobileChangeReview"; +import { validateReview } from "./useChangeReview"; + +vi.mock("../../platform", async () => { + const { createBrowserMobileRemotePlatform } = + await import("../../platform/browser"); + const platform = createBrowserMobileRemotePlatform(); + return { useMobileRemotePlatform: () => platform }; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => + options?.count === undefined ? key : `${key} (${options.count})`, + }), +})); +vi.mock("@src/scaffold/ModalSystem", () => ({ + default: ({ + children, + title, + onClose, + headerActions, + closable, + }: React.PropsWithChildren<{ + title: React.ReactNode; + onClose: () => void; + headerActions?: React.ReactNode; + closable?: boolean; + }>) => + React.createElement( + "div", + { role: "dialog" }, + title, + headerActions, + children, + closable !== false && + React.createElement("button", { onClick: onClose }, "close") + ), +})); +vi.mock("../transcript/useMobileCopyText", () => ({ + useMobileCopyText: () => ({ state: "idle", copy: vi.fn() }), +})); +vi.mock("../transcript/MobileReadonlyEditor", () => ({ + default: ({ + content, + wrap, + height, + }: { + content: string; + wrap: boolean; + height?: string; + }) => + React.createElement( + "pre", + { "data-wrap": String(wrap), "data-height": height }, + content + ), +})); +let root: ReturnType; +let host: HTMLDivElement; +const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView" +); +const file = { + path: "a.ts", + additions: 1, + deletions: 1, + patches: [], + before: null, + after: null, + availability: "patch_only", +}; +beforeEach(() => { + vi.useFakeTimers(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(private cb: (v: unknown[]) => void) {} + observe() { + this.cb([{ isIntersecting: true }]); + } + disconnect() {} + } + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + if (originalScrollIntoView) { + Object.defineProperty( + HTMLElement.prototype, + "scrollIntoView", + originalScrollIntoView + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; +}); +const button = (text: string) => + Array.from(host.querySelectorAll("button")).find( + (b) => + b.textContent?.startsWith(text) || + (text === "close" && + b.getAttribute("aria-label") === "common:actions.close") + )!; +const summaryHeading = () => + host.querySelector(".mobile-change-review__heading")!; +const selectScope = async (value: string) => { + await act(async () => { + host + .querySelector(".mobile-change-review__scope button")! + .click(); + }); + await act(async () => { + Array.from(document.querySelectorAll('[role="option"]')) + .find((option) => option.textContent?.includes(`changeReview.${value}`))! + .click(); + }); +}; +const selectMode = async (value: "diff" | "full") => { + await act(async () => { + const mode = host.querySelector( + ".mobile-change-review__mode" + )!; + mode.querySelector(`[data-tab-key="${value}"]`)!.click(); + }); +}; + +it("distinguishes pending, error/retry and empty turn without fake statistics", async () => { + const call = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue({ files: [], complete: false }); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + expect(host.textContent).toContain("changeReview.loading"); + expect(host.textContent).not.toContain("+—"); + expect( + host.querySelector(".mobile-change-review")?.getAttribute("aria-busy") + ).toBe("true"); + expect( + host.querySelectorAll( + '.mobile-change-review__loading-row[aria-hidden="true"]' + ) + ).toHaveLength(2); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("changeReview.retry"); + expect(host.textContent).toContain("changeReview.loadFailed"); + expect(host.textContent).not.toContain("changeReview.emptyTurn"); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + await act(async () => button("changeReview.retry").click()); + expect(host.textContent).toContain("changeReview.loading"); + expect(host.textContent).not.toContain("changeReview.loadFailed"); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.innerHTML).toBe(""); + call.mockResolvedValue({ files: [file], complete: false }); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "two", + online: true, + revision: "2", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("changeReview.filesCount"); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + expect(summaryHeading().hasAttribute("disabled")).toBe(false); +}); + +it("loads only the summary until opened, and never presents a patch as full file", async () => { + const call = vi.fn().mockImplementation((_method, params) => + Promise.resolve({ + complete: false, + files: [ + { + ...file, + patches: params.filePath ? ["@@ -1 +1 @@\n-old\n+new"] : [], + }, + ], + }) + ); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(call).toHaveBeenCalledTimes(1); + expect(host.querySelector("pre")).toBeNull(); + await act(async () => button("changeReview.filesCount").click()); + await act(async () => vi.advanceTimersByTimeAsync(300)); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("+new"); + expect( + host.querySelector(".mobile-patch-diff__line.is-added code")?.textContent + ).toBe("new"); + expect(call.mock.calls.filter(([, params]) => !params.filePath)).toHaveLength( + 1 + ); + await act(async () => button("close").click()); + await act(async () => button("changeReview.filesCount").click()); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(call.mock.calls.filter(([, params]) => !params.filePath)).toHaveLength( + 1 + ); + await selectMode("full"); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("changeReview.unavailable"); + await selectScope("workspace"); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect( + call.mock.calls.some(([, params]) => params.scope === "workspace") + ).toBe(true); +}); + +it("shows file rows by default and toggles them with an accessible icon without opening review", async () => { + const call = vi.fn().mockResolvedValue({ + complete: false, + files: [file, { ...file, path: "src/second.ts" }], + }); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.querySelectorAll(".mobile-change-review__row")).toHaveLength(2); + expect(summaryHeading().textContent).toContain("changeReview.filesCount (2)"); + expect(summaryHeading().textContent).toContain("+2"); + expect(summaryHeading().textContent).toContain("-2"); + expect(summaryHeading().style.width).toBe("100%"); + expect(summaryHeading().style.height).toBe("auto"); + expect(summaryHeading().style.minHeight).toBe( + "var(--mobile-change-row-height)" + ); + const collapse = host.querySelector( + '[aria-label="changeReview.collapse"]' + )!; + expect(collapse.getAttribute("aria-expanded")).toBe("true"); + expect(collapse.classList.contains("button")).toBe(true); + expect(collapse.querySelector("svg")).not.toBeNull(); + expect(collapse.querySelector(".truncate")).toBeNull(); + expect(collapse.textContent).toBe(""); + expect(collapse.style.padding).toBe("0px"); + expect(collapse.style.width).toBe("var(--mobile-change-touch-size)"); + const rows = document.getElementById( + collapse.getAttribute("aria-controls")! + )!; + expect(rows.hidden).toBe(false); + await act(async () => collapse.click()); + expect(rows.hidden).toBe(true); + expect(host.querySelectorAll(".mobile-change-review__row")).toHaveLength(0); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => + host + .querySelector('[aria-label="changeReview.expand"]')! + .click() + ); + expect(host.querySelectorAll(".mobile-change-review__row")).toHaveLength(2); + expect(call).toHaveBeenCalledTimes(1); +}); + +it("does not fetch when offline and clears pending debounce on unmount", async () => { + const call = vi.fn(); + const props = { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "one", + online: false, + revision: "1", + }; + await act(async () => + root.render(React.createElement(MobileChangeReview, props)) + ); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(call).not.toHaveBeenCalled(); + expect(host.querySelector('[role="status"]')?.textContent).toContain( + "changeReview.offline" + ); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + expect(host.textContent).not.toContain("changeReview.retry"); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { ...props, online: true }) + ) + ); + await act(async () => root.render(null)); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(call).not.toHaveBeenCalled(); +}); + +it("explains a failed load and turns repeated retry clicks into one pending request", async () => { + let finishRetry!: (value: unknown) => void; + const call = vi + .fn() + .mockRejectedValueOnce(new Error("private server failure")) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve; + }) + ); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "retry", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("changeReview.loadFailed"); + expect(host.querySelector('[role="status"]')?.textContent).toContain( + "changeReview.loadFailed" + ); + expect(host.textContent).not.toContain("private server failure"); + const retry = button("changeReview.retry"); + expect(retry.classList.contains("button")).toBe(true); + expect(retry.classList.contains("mobile-change-review__retry")).toBe(true); + expect(retry.style.height).toBe("var(--mobile-change-touch-size)"); + expect(retry.style.padding).toBe("0 var(--mobile-change-action-padding)"); + expect(retry.querySelector("svg")).not.toBeNull(); + await act(async () => { + retry.click(); + retry.click(); + }); + expect(host.textContent).toContain("changeReview.loading"); + expect(host.textContent).not.toContain("changeReview.loadFailed"); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + expect(host.querySelectorAll(".mobile-change-review__row")).toHaveLength(0); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(call).toHaveBeenCalledTimes(2); + expect(host.textContent).toContain("changeReview.loading"); + await act(async () => + finishRetry({ complete: false, files: [{ ...file, path: "recovered.ts" }] }) + ); + expect(summaryHeading()).toBeInstanceOf(HTMLButtonElement); + expect(summaryHeading().hasAttribute("disabled")).toBe(false); + expect(host.textContent).toContain("recovered.ts"); + expect(host.textContent).not.toContain("changeReview.retry"); +}); + +it.each(["pending", "error", "offline"] as const)( + "preserves explicit review opening and scope selection while the turn is %s", + async (state) => { + const call = vi.fn().mockImplementation((_method, params) => { + if (params.scope === "turn") { + return state === "error" + ? Promise.reject(new Error("turn load failed")) + : new Promise(() => {}); + } + return Promise.resolve({ complete: false, files: [file] }); + }); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "alternate-scope", + roundId: "one", + online: state !== "offline", + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + const heading = summaryHeading(); + expect(heading).toBeInstanceOf(HTMLButtonElement); + expect(heading.hasAttribute("disabled")).toBe(false); + expect(heading.getAttribute("aria-haspopup")).toBe("dialog"); + expect(heading.style.height).toBe("auto"); + expect(heading.style.minHeight).toBe("var(--mobile-change-row-height)"); + await act(async () => heading.click()); + expect(host.querySelector('[role="dialog"]')).not.toBeNull(); + await selectScope("session"); + await act(async () => vi.advanceTimersByTimeAsync(300)); + if (state === "offline") { + expect(call).not.toHaveBeenCalled(); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain( + "changeReview.offline" + ); + } else { + expect( + call.mock.calls.filter(([, params]) => params.scope === "turn") + ).toHaveLength(1); + expect( + call.mock.calls.some(([, params]) => params.scope === "session") + ).toBe(true); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain( + "a.ts" + ); + } + } +); + +it("preserves zero-stat files without making up unknown totals", async () => { + const call = vi.fn().mockResolvedValue({ + complete: false, + files: [ + { ...file, path: "unknown.ts", additions: null, deletions: null }, + { ...file, path: "unchanged-count.ts", additions: 0, deletions: 0 }, + ], + }); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "counts", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(summaryHeading().textContent).toContain("changeReview.filesCount (2)"); + expect(summaryHeading().textContent).not.toContain("+0"); + expect(summaryHeading().textContent).not.toContain("-0"); + const [unknown, known] = host.querySelectorAll(".mobile-change-review__row"); + expect(unknown.textContent).toContain("unknown.ts"); + expect(unknown.textContent).not.toContain("+0"); + expect(unknown.textContent).not.toContain("-0"); + expect(known.textContent).toContain("unchanged-count.ts"); + expect(host.querySelectorAll(".mobile-change-review__row")).toHaveLength(2); +}); + +it("shows the filename before a compact directory and opens the exact long path", async () => { + const path = + "/Users/example/projects/remote-client/src/modules/MobileRemote/components/StatusBadge.test.tsx"; + const call = vi.fn().mockImplementation((_method, params) => + Promise.resolve({ + complete: false, + files: [ + { + ...file, + path, + patches: params.filePath ? ["@@ -1 +1 @@\n-old\n+new"] : [], + }, + ], + }) + ); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "long-file", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + const row = host.querySelector( + ".mobile-change-review__row" + )!; + const pathLabel = row.querySelector( + ".mobile-change-review__path" + )!; + expect( + pathLabel.querySelector(".mobile-change-review__path-filename")?.textContent + ).toBe("StatusBadge.test.tsx"); + expect( + pathLabel.querySelector(".mobile-change-review__path-directory") + ?.textContent + ).toBe("…/MobileRemote/components"); + expect(pathLabel.textContent).not.toContain("/Users/example"); + expect(pathLabel.title).toBe(path); + expect(row.dataset.mobileChangePath).toBe(path); + expect(row.classList.contains("button")).toBe(true); + expect(row.style.width).toBe("100%"); + expect(row.style.minWidth).toBe("0px"); + expect(row.style.height).toBe("auto"); + expect(row.style.minHeight).toBe("var(--mobile-change-row-height)"); + expect(row.style.padding).toBe("var(--mobile-change-row-padding)"); + expect(row.textContent).toContain("+1"); + expect(row.textContent).toContain("-1"); + await act(async () => row.click()); + expect(host.querySelector('[role="dialog"]')).not.toBeNull(); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(call.mock.calls.filter(([, params]) => !params.filePath)).toHaveLength( + 1 + ); + expect(call.mock.calls.some(([, params]) => params.filePath === path)).toBe( + true + ); +}); + +it("rejects malformed remote data instead of interpreting missing counts as zero", () => { + expect(() => + validateReview({ complete: true, files: [{ ...file, additions: -1 }] }) + ).toThrow(); + expect(() => + validateReview({ complete: true, files: [{ ...file, after: {} }] }) + ).toThrow(); + expect( + validateReview({ complete: false, files: [{ ...file, additions: null }] }) + .files[0].additions + ).toBeNull(); +}); + +it("ignores a late manifest from the previously selected session", async () => { + let finishOld!: (value: unknown) => void; + const call = vi.fn().mockImplementation((_method, params) => + params.sessionId === "old" + ? new Promise((resolve) => { + finishOld = resolve; + }) + : Promise.resolve({ + complete: false, + files: [{ ...file, path: "new.ts" }], + }) + ); + const props = { + client: { call } as unknown as MobileRpcClient, + sessionId: "old", + roundId: "one", + online: true, + revision: "1", + }; + await act(async () => + root.render(React.createElement(MobileChangeReview, props)) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { ...props, sessionId: "new" }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.textContent).toContain("new.ts"); + await act(async () => + finishOld({ complete: false, files: [{ ...file, path: "old.ts" }] }) + ); + expect(host.textContent).toContain("new.ts"); + expect(host.textContent).not.toContain("old.ts"); +}); + +it("labels a before-only historical snapshot instead of implying it is the current file", async () => { + const call = vi.fn().mockImplementation((_method, params) => + Promise.resolve({ + complete: false, + files: [ + { + ...file, + before: params.filePath ? "captured before edit" : null, + availability: "before_snapshot_only", + }, + ], + }) + ); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "a", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + await act(async () => button("changeReview.filesCount").click()); + await selectMode("full"); + await act(async () => vi.advanceTimersByTimeAsync(300)); + expect(host.querySelector("pre")?.textContent).toBe("captured before edit"); + expect(button("changeReview.before").disabled).toBe(true); + expect(host.textContent).not.toContain("changeReview.after"); +}); + +it("keeps identity compact, details opt-in and icon actions available across detail loading", async () => { + const namedFile = { ...file, path: "src/features/review/example.ts" }; + let resolveDetail!: (value: unknown) => void; + const call = vi.fn().mockImplementation((_method, params) => + params.filePath + ? new Promise((resolve) => { + resolveDetail = resolve; + }) + : Promise.resolve({ complete: false, files: [namedFile] }) + ); + await act(async () => + root.render( + React.createElement(MobileChangeReview, { + client: { call } as unknown as MobileRpcClient, + sessionId: "compact", + roundId: "one", + online: true, + revision: "1", + }) + ) + ); + await act(async () => vi.advanceTimersByTimeAsync(300)); + await act(async () => button("changeReview.filesCount").click()); + await act(async () => vi.advanceTimersByTimeAsync(300)); + const heading = host.querySelector(".mobile-change-review__file-heading")!; + expect(heading.textContent).toContain("example.ts"); + expect(heading.textContent).toContain("+1"); + expect(heading.textContent).toContain("-1"); + expect( + heading.querySelector(".mobile-change-review__file-toggle") + ).not.toBeNull(); + expect(heading.textContent).toContain("src/features"); + const disclosures = [...host.querySelectorAll("details")]; + expect(disclosures).toHaveLength(3); + expect(disclosures.every((detail) => !detail.open)).toBe(true); + expect( + host.querySelector( + '.mobile-change-review__file [data-state="loading"][role="status"]' + ) + ).not.toBeNull(); + const copy = host.querySelector( + '[aria-label="changeReview.copy"]' + )!; + expect(copy.disabled).toBe(true); + expect(copy.textContent).toBe(""); + await act(async () => + resolveDetail({ + complete: false, + files: [{ ...namedFile, after: "loaded file" }], + }) + ); + expect(copy.disabled).toBe(false); + expect(host.querySelector("pre")?.dataset.wrap).toBe("true"); + await act(async () => + host + .querySelector('[aria-label="changeReview.wrap"]')! + .click() + ); + expect(host.querySelector("pre")?.dataset.wrap).toBe("false"); + const requestCount = call.mock.calls.length; + await act(async () => { + host.querySelector( + ".mobile-change-review__file-path" + )!.open = true; + }); + expect( + host.querySelector(".mobile-change-review__file-path")!.textContent + ).toContain(namedFile.path); + expect(call).toHaveBeenCalledTimes(requestCount); +}); diff --git a/src/modules/MobileRemote/components/changes/MobileChangeReview.tsx b/src/modules/MobileRemote/components/changes/MobileChangeReview.tsx new file mode 100644 index 0000000000..80842a1e25 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeReview.tsx @@ -0,0 +1,625 @@ +import React, { Suspense, lazy, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import DiffStatsBadge from "@src/components/DiffStatsBadge"; +import Dropdown from "@src/components/Dropdown"; +import type { DropdownOption } from "@src/components/Dropdown/types"; +import TabPill from "@src/components/TabPill"; +import { + ArrowDown01Icon, + Cancel01Icon, + ChevronsDownUpIcon, + Copy01Icon, + HugeiconsIcon, + MoreHorizontalIcon, + TextWrapIcon, + Tick01Icon, + UnfoldMoreIcon, +} from "@src/icons"; +import Modal from "@src/scaffold/ModalSystem"; + +import type { MobileRpcClient } from "../../connection/mobileRpcClient"; +import { useMobileRemotePlatform } from "../../platform"; +import "../transcript/mobileToolPreview.scss"; +import { useMobileCopyText } from "../transcript/useMobileCopyText"; +import { MobileChangeFileHeader } from "./MobileChangeFileHeader"; +import { MobileChangeReviewState } from "./MobileChangeReviewState"; +import { MobileChangeSummary } from "./MobileChangeSummary"; +import { MobilePatchDiff } from "./MobilePatchDiff"; +import "./mobileChangeReview.scss"; +import { + type ChangeFile, + type ChangeScope, + useChangeReview, +} from "./useChangeReview"; + +const Editor = lazy(() => import("../transcript/MobileReadonlyEditor")); +const iconStyle: React.CSSProperties = { + width: "var(--mobile-change-touch-size)", + height: "var(--mobile-change-touch-size)", + padding: 0, + borderRadius: "var(--mobile-change-action-radius)", +}; +type ExpansionCommand = { expanded: boolean }; +interface Props { + client: MobileRpcClient | null; + sessionId: string; + roundId: string | null; + online: boolean; + revision: string; +} + +function ReviewDropdown({ + label, + value, + options, + onSelect, + searchable = false, +}: { + label: string; + value: string; + options: DropdownOption[]; + onSelect: (value: string) => void; + searchable?: boolean; +}) { + const { runtime } = useMobileRemotePlatform(); + const [open, setOpen] = useState(false); + const container = runtime.portalContainer?.(); + return ( +
{ + // Portaled menu events retain React ancestry. Keep Escape at the open + // dropdown boundary instead of reaching the parent Modal's document handler. + if (open && event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + event.currentTarget.querySelector("button")?.focus(); + } + }} + > + container as HTMLElement : undefined + } + avoidViewportOverflow + className={`mobile-change-review-menu ${searchable ? "mobile-change-review-menu--files" : "mobile-change-review-menu--scope"}`} + popupVisible={open} + onVisibleChange={setOpen} + options={options} + value={value} + showSearch={searchable} + onSelect={(next) => { + if (typeof next === "string") onSelect(next); + }} + > + + +
+ ); +} + +export function MobileChangeReview(props: Props) { + const { runtime } = useMobileRemotePlatform(); + const [visible, setVisible] = useState(() => !runtime.isHidden()); + useEffect(() => { + const update = () => setVisible(!runtime.isHidden()); + update(); + return runtime.subscribeVisibility(update); + }, [runtime]); + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(true); + const [selected, setSelected] = useState(); + const enabled = props.online && visible; + const review = useChangeReview( + props.client, + props.sessionId, + props.roundId, + "turn", + enabled, + props.revision + ); + const files = review.value?.files; + const panel = open ? ( + setOpen(false)} + /> + ) : null; + // Product policy: a successful empty turn has no footer. Keep an already + // opened review mounted so refresh cannot dismiss another selected scope. + if (files?.length === 0) return panel; + return ( + <> + setExpanded((value) => !value)} + onOpen={(path) => { + setSelected(path); + setOpen(true); + }} + onRetry={review.retry} + /> + {panel} + + ); +} + +function ReviewPanel( + props: Props & { + selected?: string; + onClose: () => void; + turnReview: ReturnType; + } +) { + const { t } = useTranslation("mobileRemote"); + const initialFocusRef = useRef(null); + const [scope, setScope] = useState("turn"); + const [full, setFull] = useState(false); + const [selected, setSelected] = useState(props.selected); + const [expandAll, setExpandAll] = useState(null); + const scopedReview = useChangeReview( + props.client, + props.sessionId, + props.roundId, + scope, + props.online && scope !== "turn", + props.revision + ); + // The footer owns the turn manifest. Opening its panel must not issue the + // same request a second time (or maintain an independent stale result). + const review = scope === "turn" ? props.turnReview : scopedReview; + const files = review.value?.files; + const selectedPath = + files?.find((file) => file.path === selected)?.path ?? files?.[0]?.path; + const total = (key: "additions" | "deletions") => + files?.every((file) => file[key] !== null) + ? files.reduce((sum, file) => sum + file[key]!, 0) + : null; + const additions = total("additions"); + const deletions = total("deletions"); + return ( + + + {files + ? t("changeReview.filesCount", { count: files.length }) + : t("changeReview.title")} + + {files && ( +
+ {additions !== null && deletions !== null && ( + + )} +
+ )} +
+ } + initialFocusRef={initialFocusRef} + closable={false} + headerActions={ +
+
+ } + onClose={props.onClose} + bodyClassName="mobile-change-review__body" + > +
+
+ {t("changeReview.scope")} + ({ value, label: t(`changeReview.${value}`) }) + )} + onSelect={(value) => { + if ( + value === "turn" || + value === "session" || + value === "workspace" + ) { + setScope(value); + setSelected(undefined); + } + }} + /> +
+
+ {t("changeReview.selected")} + ) : undefined, + }, + { + key: "full", + label: t("changeReview.full"), + badge: full ? ( + {t("changeReview.selected")} + ) : undefined, + }, + ]} + onChange={(value) => setFull(value === "full")} + /> +
+
+ {!props.online ? ( + + ) : review.error ? ( + + ) : !review.value ? ( + + ) : ( + <> + {!review.value.complete && ( +
+ {t("fileViewer.partial")} +

{t("changeReview.partial")}

+
+ )} + {review.value.files.length === 0 && ( + + )} + {full && review.value.files.length > 1 && ( +
+ ({ + value: file.path, + label: file.path, + }))} + onSelect={setSelected} + searchable + /> +
+ )} + {(full + ? review.value.files.filter((f) => f.path === selectedPath) + : review.value.files + ).map((file, index) => ( + { + setSelected(file.path); + setFull(true); + }} + expandAll={expandAll} + selected={selected !== undefined && file.path === selectedPath} + initialOpen={ + full || file.path === selectedPath || (!selected && index === 0) + } + /> + ))} + + )} + + ); +} + +function FileReview( + props: Props & { + scope: ChangeScope; + file: ChangeFile; + full: boolean; + initialOpen: boolean; + expandAll: ExpansionCommand | null; + selected: boolean; + onOpenFull: () => void; + } +) { + const { t } = useTranslation("mobileRemote"); + const [expansion, setExpansion] = useState<{ + base: ExpansionCommand | null; + value: boolean; + } | null>(null); + const expanded = + props.full || + (expansion?.base === props.expandAll + ? expansion.value + : (props.expandAll?.expanded ?? props.initialOpen)); + const [element, setElement] = useState(null); + const [onScreen, setOnScreen] = useState(false); + useEffect(() => { + if (props.selected) element?.scrollIntoView({ block: "nearest" }); + }, [element, props.selected]); + useEffect(() => { + if (!element) return; + const observer = new IntersectionObserver((entries) => + setOnScreen(entries.some((entry) => entry.isIntersecting)) + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + const [before, setBefore] = useState(false); + const [wrap, setWrap] = useState(true); + const detail = useChangeReview( + props.client, + props.sessionId, + props.roundId, + props.scope, + props.online && expanded && onScreen, + props.revision, + props.file.path + ); + const file = detail.value?.files.find((f) => f.path === props.file.path); + const showBefore = file?.before != null && (before || file.after == null); + const content = props.full + ? showBefore + ? file?.before + : (file?.after ?? file?.before) + : file?.patches.length + ? file.patches.join("\n") + : file?.after; + const parts = props.file.path.split(/[\\/]/); + parts.pop(); + const directory = parts.join("/"); + const copy = useMobileCopyText(content ?? ""); + return ( +
+ + setExpansion({ base: props.expandAll, value: !expanded }) + } + /> + {expanded && onScreen ? ( +
+ + +
+ {directory && ( +
+ + {directory} + +

{props.file.path}

+
+ )} + {props.full && ( + + )} +
+
+ ) : undefined} + {expanded && onScreen && ( + <> + + {copy.state === "copied" ? t("changeReview.copied") : ""} + + {copy.state === "failed" && ( +

+ {t("changeReview.copyFailed")} +

+ )} + {!props.online ? ( + + ) : detail.error ? ( + + ) : !detail.value ? ( + + ) : !file || content == null ? ( + + ) : ( +
+ {props.full && + file.availability === "reconstructed_from_history" && ( +

+ {t("changeReview.reconstructed")} +

+ )} + {!props.full && file.patches.length > 0 ? ( + + ) : ( + } + > + + + )} +
+ )} + + )} +
+ ); +} diff --git a/src/modules/MobileRemote/components/changes/MobileChangeReviewState.test.ts b/src/modules/MobileRemote/components/changes/MobileChangeReviewState.test.ts new file mode 100644 index 0000000000..affe1c6a7b --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeReviewState.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { MobileChangeReviewState } from "./MobileChangeReviewState"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let host: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; +}); + +it("replaces retry with a named static loading state without starting its own work", async () => { + const onRetry = vi.fn(); + await act(async () => + root.render( + React.createElement(MobileChangeReviewState, { state: "error", onRetry }) + ) + ); + const button = host.querySelector("button")!; + expect(host.textContent).toContain("changeReview.loadFailed"); + expect(button.style.height).toBe("var(--mobile-change-state-touch)"); + expect(button.querySelector("svg")).not.toBeNull(); + await act(async () => button.click()); + expect(onRetry).toHaveBeenCalledTimes(1); + await act(async () => + root.render( + React.createElement(MobileChangeReviewState, { + state: "loading", + onRetry, + }) + ) + ); + expect(host.querySelector("button")).toBeNull(); + expect(host.querySelector('[role="status"]')?.getAttribute("aria-busy")).toBe( + "true" + ); + expect(host.textContent).toContain("changeReview.loading"); + expect( + host.querySelector('.mobile-change-state__skeleton[aria-hidden="true"]') + ?.children + ).toHaveLength(3); + expect(onRetry).toHaveBeenCalledTimes(1); +}); + +it.each([ + ["offline", "changeReview.offline"], + ["empty", "changeReview.empty"], + ["unavailable", "changeReview.unavailable"], +] as const)( + "keeps %s distinct and does not offer an invalid retry", + async (state, message) => { + const onRetry = vi.fn(); + await act(async () => + root.render( + React.createElement(MobileChangeReviewState, { + state, + onRetry, + compact: true, + }) + ) + ); + expect( + host.querySelector('[role="status"]')?.getAttribute("aria-busy") + ).toBe("false"); + expect(host.textContent).toBe(message); + expect(host.querySelector("button")).toBeNull(); + expect(host.querySelector(".mobile-change-state--compact")).not.toBeNull(); + expect(onRetry).not.toHaveBeenCalled(); + } +); + +it("does not render a dead retry button without a request owner", async () => { + await act(async () => + root.render( + React.createElement(MobileChangeReviewState, { state: "error" }) + ) + ); + expect(host.textContent).toBe("changeReview.loadFailed"); + expect(host.querySelector("button")).toBeNull(); +}); diff --git a/src/modules/MobileRemote/components/changes/MobileChangeReviewState.tsx b/src/modules/MobileRemote/components/changes/MobileChangeReviewState.tsx new file mode 100644 index 0000000000..b7c478c684 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeReviewState.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import SkeletonBar from "@src/components/Skeleton"; +import { + AlertCircleIcon, + FileSearchIcon, + HugeiconsIcon, + Plug01Icon, + Refresh04Icon, +} from "@src/icons"; +import { classNames } from "@src/util/ui/classNames"; + +import "./mobileChangeReviewState.scss"; + +interface Props { + state: "loading" | "error" | "offline" | "empty" | "unavailable"; + onRetry?: () => void; + compact?: boolean; +} + +const messages = { + loading: "changeReview.loading", + error: "changeReview.loadFailed", + offline: "changeReview.offline", + empty: "changeReview.empty", + unavailable: "changeReview.unavailable", +} as const; + +/** Presentation only: request identity, cancellation and retry stay in the hook. */ +export function MobileChangeReviewState({ + state, + onRetry, + compact = false, +}: Props) { + const { t } = useTranslation("mobileRemote"); + const loading = state === "loading"; + return ( +
+ {loading ? ( + <> + {t(messages.loading)} + + + ) : ( + <> + +

{t(messages[state])}

+ {state === "error" && onRetry && ( + + )} + + )} +
+ ); +} diff --git a/src/modules/MobileRemote/components/changes/MobileChangeSummary.tsx b/src/modules/MobileRemote/components/changes/MobileChangeSummary.tsx new file mode 100644 index 0000000000..3973bc3d75 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobileChangeSummary.tsx @@ -0,0 +1,226 @@ +import React, { useId } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import DiffStatsBadge from "@src/components/DiffStatsBadge"; +import SkeletonBar from "@src/components/Skeleton"; +import { ArrowDown01Icon, HugeiconsIcon, Refresh04Icon } from "@src/icons"; +import { + getCompactPathLabel, + getDirectory, + getFileName, + getPathSegments, +} from "@src/util/file/pathUtils"; + +import type { ChangeFile } from "./useChangeReview"; + +interface Props { + files: readonly ChangeFile[] | undefined; + online: boolean; + error: boolean; + expanded: boolean; + onToggle: () => void; + onOpen: (path?: string) => void; + onRetry: () => void; +} + +// Button owns inline geometry. Keep the mobile card's dimensions at that +// boundary too, with values owned by the scoped stylesheet. +const rowStyle: React.CSSProperties = { + height: "auto", + minHeight: "var(--mobile-change-row-height)", + padding: "var(--mobile-change-row-padding)", + borderRadius: 0, + fontSize: "inherit", +}; + +function Stats({ + additions, + deletions, +}: Pick) { + return ( + + {additions !== null && deletions !== null ? ( + + ) : ( + — + )} + + ); +} + +function FilePath({ path }: { path: string }) { + const directory = getDirectory(path); + const compactDirectory = getCompactPathLabel(directory, 2); + const directoryLabel = + getPathSegments(directory).length > 2 + ? `…/${compactDirectory}` + : compactDirectory; + + return ( + + + {getFileName(path)} + + {directoryLabel && ( + + {directoryLabel} + + )} + + ); +} + +/** A presentation-only card; the parent owns the manifest, retry and viewer. */ +export function MobileChangeSummary({ + files, + online, + error, + expanded, + onToggle, + onOpen, + onRetry, +}: Props) { + const { t } = useTranslation("mobileRemote"); + const rowsId = useId(); + const ready = !!files?.length; + const loading = online && !error && !files; + const total = (key: "additions" | "deletions") => + files?.every((file) => file[key] !== null) + ? files.reduce((sum, file) => sum + file[key]!, 0) + : null; + const heading = ( + + + {ready + ? t("changeReview.filesCount", { count: files.length }) + : t("changeReview.title")} + + {ready && ( + + )} + + ); + + return ( +
+
+ + {ready && ( +
+ {!online || error ? ( +
+

+ {t(!online ? "changeReview.offline" : "changeReview.loadFailed")} +

+ {online && error && ( + + )} +
+ ) : loading ? ( +
+ {t("changeReview.loading")} + + +
+ ) : null} + +
+ ); +} diff --git a/src/modules/MobileRemote/components/changes/MobilePatchDiff.tsx b/src/modules/MobileRemote/components/changes/MobilePatchDiff.tsx new file mode 100644 index 0000000000..6732b1f6d4 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/MobilePatchDiff.tsx @@ -0,0 +1,142 @@ +import React, { Suspense, lazy, memo, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import DiffStatsBadge from "@src/components/DiffStatsBadge"; +import { ArrowDown01Icon, HugeiconsIcon } from "@src/icons"; + +import { MobileChangeReviewState } from "./MobileChangeReviewState"; +import { parseReviewPatch } from "./parseReviewPatch"; + +const Editor = lazy(() => import("../transcript/MobileReadonlyEditor")); +const PAGE_LINES = 80; + +export const MobilePatchDiff = memo(function MobilePatchDiff({ + patches, + filePath, + wrap, +}: { + patches: string[]; + filePath: string; + wrap: boolean; +}) { + const { t } = useTranslation("mobileRemote"); + const parsed = useMemo(() => patches.map(parseReviewPatch), [patches]); + const [limit, setLimit] = useState(PAGE_LINES); + // Unknown formats are readable, never silently discarded as an empty diff. + if (parsed.some((hunks) => hunks === null)) + return ( + <> +

+ {t("changeReview.rawPatch")} +

+ } + > + + + + ); + const visible = []; + let offset = 0; + for (const [patchIndex, hunks] of parsed.entries()) { + for (const [hunkIndex, hunk] of hunks!.entries()) { + const lines = hunk.lines.slice(0, Math.max(0, limit - offset)); + offset += hunk.lines.length; + if (lines.length) visible.push({ patchIndex, hunkIndex, hunk, lines }); + } + } + const total = parsed.reduce( + (count, hunks) => + count + hunks!.reduce((sum, hunk) => sum + hunk.lines.length, 0), + 0 + ); + return ( +
+ {visible.map(({ patchIndex, hunkIndex, hunk, lines }) => { + const start = hunk.newCount ? hunk.newStart : hunk.oldStart; + const count = hunk.newCount || hunk.oldCount; + return ( +
+ + +
+ {lines.map((line, index) => ( +
+ + + {line.kind === "added" + ? "+" + : line.kind === "deleted" + ? "−" + : " "} + + + {line.text || " "} + {line.noNewline && ( + + {" "} + {t("changeReview.noNewline")} + + )} + +
+ ))} +
+
+ ); + })} + {total > limit && ( + + )} +
+ ); +}); diff --git a/src/modules/MobileRemote/components/changes/mobileChangeReview.scss b/src/modules/MobileRemote/components/changes/mobileChangeReview.scss new file mode 100644 index 0000000000..de368d8758 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/mobileChangeReview.scss @@ -0,0 +1,649 @@ +@use "../../mobileUiTokens" as tokens; + +.mobile-change-review { + --mobile-change-touch-size: #{tokens.$touch-target}; + --mobile-change-row-height: #{tokens.$touch-target + tokens.$space-1}; + --mobile-change-row-padding: #{tokens.$space-3} #{tokens.$space-4}; + --mobile-change-action-padding: #{tokens.$space-2}; + --mobile-change-action-radius: #{tokens.$space-2}; + + margin-block: tokens.$space-4; + min-width: 0; + width: 100%; + border: 1px solid var(--color-border-2); + border-radius: tokens.$space-4; + overflow: hidden; + background: var(--color-bg-2); + color: var(--color-text-1); + font-size: var(--mobile-type-secondary-size); + line-height: var(--mobile-type-body-leading); + &__summary { + display: flex; + align-items: center; + min-width: 0; + padding-inline-end: tokens.$space-1; + background: var(--color-fill-1); + } + &__heading-content, + &__row-content { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: tokens.$space-3; + } + &__path { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: tokens.$space-1; + text-align: start; + } + &__path-filename, + &__path-directory { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &__path-filename { + color: var(--color-text-1); + font-weight: var(--mobile-type-medium-weight); + } + &__path-directory { + color: var(--color-text-3); + font-size: var(--mobile-type-caption-size); + line-height: var(--mobile-type-control-leading); + } + &__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + &__stats { + display: inline-flex; + flex-shrink: 0; + gap: tokens.$space-2; + font-size: var(--mobile-type-caption-size); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + &__chevron { + color: var(--color-text-3); + } + &__chevron.is-expanded { + transform: rotate(180deg); + } + &__unknown { + color: var(--color-text-3); + } + &__status { + display: flex; + align-items: center; + gap: tokens.$space-2; + padding: tokens.$space-2 tokens.$space-4; + min-height: var(--mobile-change-row-height); + border-top: 1px solid var(--color-border-2); + } + &__status-copy { + flex: 1; + min-width: 0; + margin: 0; + color: var(--color-text-3); + overflow-wrap: anywhere; + } + &__retry { + flex-shrink: 0; + color: var(--color-primary-6); + } + &__loading-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--mobile-change-row-height); + padding: var(--mobile-change-row-padding); + gap: tokens.$space-4; + border-top: 1px solid var(--color-border-2); + } + &__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: tokens.$space-2; + padding: tokens.$space-2; + } + &__file { + margin-block: tokens.$space-4; + border: 1px solid var(--color-border-2); + border-radius: tokens.$space-3; + overflow: hidden; + } + &__editor { + height: 50dvh; + min-height: 12rem; + } +} + +.mobile-change-review .mobile-change-review__heading, +.mobile-change-review .mobile-change-review__row, +.mobile-change-review .mobile-change-review__toggle { + border: 0; + border-radius: 0; + box-shadow: none; + font-weight: var(--mobile-type-regular-weight); + color: var(--color-text-1); + font-size: inherit; +} +.mobile-change-review .mobile-change-review__heading { + flex: 1; + min-width: 0; + justify-content: flex-start; + text-align: start; +} +.mobile-change-review__heading-content { + font-weight: var(--mobile-type-medium-weight); +} +.mobile-change-review .mobile-change-review__toggle { + flex-shrink: 0; +} +.mobile-change-review .mobile-change-review__row { + display: flex; + width: 100%; + border-top: 1px solid var(--color-border-2); + text-align: start; +} +.mobile-change-review__heading > span, +.mobile-change-review__row > span { + width: 100%; +} +.mobile-change-review button:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; +} +.mobile-change-review button, +.mobile-change-review-sheet button { + min-height: tokens.$touch-target; +} + +.mobile-change-review-sheet { + --mobile-change-touch-size: #{tokens.$touch-target}; + --mobile-change-action-padding: #{tokens.$space-2}; + --mobile-change-action-radius: #{tokens.$space-2}; + + background: var(--color-bg-2); + color: var(--color-text-1); + + .mobile-change-review__title { + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + gap: tokens.$space-1; + font-size: var(--mobile-type-title-size); + font-weight: var(--mobile-type-emphasis-weight); + line-height: var(--mobile-type-title-leading); + text-align: center; + outline: none; + } + .mobile-change-review__totals { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: tokens.$space-1 tokens.$space-2; + color: var(--color-text-3); + font-size: var(--mobile-type-caption-size); + font-weight: var(--mobile-type-regular-weight); + line-height: var(--mobile-type-control-leading); + } + .mobile-change-review__close { + flex-shrink: 0; + color: var(--color-text-2); + background: var(--color-fill-2); + } + .mobile-change-review__body { + padding: 0 tokens.$space-4 + calc(#{tokens.$space-4} + env(safe-area-inset-bottom, 0px)); + min-height: 0; + overscroll-behavior: contain; + } + .mobile-change-review__controls { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-direction: column; + gap: tokens.$space-2; + padding-block: 0 tokens.$space-4; + background: var(--color-bg-2); + } + .mobile-change-review__toolbar { + padding: 0; + flex-wrap: nowrap; + gap: tokens.$space-1; + } + .mobile-change-review__mode { + min-width: 0; + padding: tokens.$space-1; + border: 0; + border-radius: tokens.$space-3; + background: var(--color-fill-2); + + button { + min-width: 0; + font-size: var(--mobile-type-secondary-size); + padding-inline: tokens.$space-2; + background: transparent; + color: var(--color-text-2); + border-radius: tokens.$space-2; + + &[data-active="true"] { + color: var(--color-text-1); + background: var(--color-bg-2); + box-shadow: var(--shadow-sm); + + // The selected badge is screen-reader-only and needs no visual gap. + > div { + gap: 0; + } + } + } + } + .mobile-change-review__scope { + width: 100%; + display: flex; + align-items: center; + gap: tokens.$space-2; + color: var(--color-text-3); + font-size: var(--mobile-type-caption-size); + > :last-child { + flex: 1; + min-width: 0; + } + .mobile-change-review__dropdown-trigger { + justify-content: flex-end; + border-color: transparent; + > span { + justify-content: flex-end; + } + } + .mobile-change-review__dropdown-label { + flex: initial; + } + } + .mobile-change-review__mode { + width: 100%; + max-width: 100%; + } + .mobile-change-review__file-picker { + display: block; + margin-block-end: tokens.$space-3; + } + .mobile-change-review__dropdown-trigger { + min-width: 0; + width: 100%; + color: var(--color-text-2); + background: var(--color-bg-2); + font-size: var(--mobile-type-secondary-size); + border: 1px solid var(--color-border-2); + border-radius: tokens.$space-2; + padding-inline: tokens.$space-2; + display: flex; + gap: tokens.$space-2; + > span { + width: 100%; + min-width: 0; + gap: tokens.$space-2; + display: flex; + align-items: center; + > svg { + flex-shrink: 0; + } + } + } + .mobile-change-review__dropdown > .dropdown-trigger-wrapper { + width: 100%; + } + .mobile-change-review__dropdown-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: start; + } + .mobile-change-review__icon { + flex-shrink: 0; + color: var(--color-text-2); + + &[aria-pressed="true"] { + color: var(--color-text-1); + background: var(--color-fill-2); + } + } + .mobile-change-review__expand-all, + .mobile-change-review__wrap { + margin-inline-start: auto; + } + .mobile-change-review__file { + display: flex; + flex-direction: column; + isolation: isolate; + margin-block: 0 tokens.$space-3; + border-color: var(--color-border-1); + border-radius: tokens.$space-4; + background: var(--color-bg-2); + + .mobile-change-review__file-heading { + background: var(--color-fill-1); + } + > .mobile-change-review__toolbar { + padding: tokens.$space-1 tokens.$space-2; + border-top: 1px solid var(--color-border-1); + flex-wrap: wrap; + } + } + .mobile-change-review__version { + flex-shrink: 0; + color: var(--color-text-2); + font-size: var(--mobile-type-caption-size); + } + .mobile-change-review__editor--diff { + height: auto; + min-height: 0; + max-height: 50dvh; + + .mobile-readonly-editor, + .cm-editor { + height: auto; + min-height: 0; + } + + .cm-scroller { + max-height: 50dvh; + overflow: auto; + overscroll-behavior: contain; + } + } + .mobile-change-review__disclosure { + color: var(--color-text-3); + font-size: var(--mobile-type-caption-size); + summary { + display: flex; + align-items: center; + min-height: tokens.$touch-target; + cursor: pointer; + list-style: none; + &::-webkit-details-marker { + display: none; + } + } + p { + margin: 0 0 tokens.$space-3; + overflow-wrap: anywhere; + } + } + .mobile-change-review__file-path { + flex: 1; + min-width: 0; + + summary { + gap: tokens.$space-1; + + > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + > svg { + flex-shrink: 0; + } + } + &[open] { + flex-basis: 100%; + + summary > svg { + transform: rotate(180deg); + } + } + } + .mobile-change-review__copy-error { + margin: 0; + padding: tokens.$space-2 tokens.$space-3; + color: var(--color-danger-6); + font-size: var(--mobile-type-caption-size); + } + button:focus-visible, + summary:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; + } +} + +.mobile-change-review-menu.mobile-change-review-menu { + @include tokens.adaptive-menu; + &--files { + min-width: min(16rem, var(--mobile-menu-available-width)); + } +} + +// Keep the shared Modal's header, focus and dismissal behavior. Equal side +// columns center the title independently of the single trailing close action. +.liquid-modal-content.modal-fullscreen.mobile-change-review-sheet + > :first-child { + display: grid; + grid-template-columns: var(--mobile-change-touch-size) minmax(0, 1fr) var( + --mobile-change-touch-size + ); + gap: tokens.$space-2; + padding: tokens.$space-2 tokens.$space-4 tokens.$space-3; + background: var(--color-bg-2); + + > :first-child { + grid-column: 2; + grid-row: 1; + justify-content: center; + + > div { + width: 100%; + min-width: 0; + } + } + > :last-child { + display: contents; + > div { + display: contents; + } + .mobile-change-review__header-actions { + display: contents; + } + .mobile-change-review__close { + grid-column: 1; + grid-row: 1; + } + .mobile-change-review__expand-all { + grid-column: 3; + grid-row: 1; + } + } +} + +// Scope and view mode have different hierarchy; only the view is segmented. +.liquid-modal-content.modal-fullscreen.mobile-change-review-sheet { + height: calc(100% - env(safe-area-inset-top, 0px) - #{tokens.$space-3}); + align-self: flex-end; + border-radius: (tokens.$space-4 + tokens.$space-2) + (tokens.$space-4 + tokens.$space-2) 0 0 !important; // Override the shared fullscreen modal's square-corner rule locally. + padding-top: tokens.$space-4; + position: relative; + &::before { + content: ""; + position: absolute; + top: tokens.$space-2; + left: 50%; + transform: translateX(-50%); + width: tokens.$space-4 * 2; + height: tokens.$space-1; + border-radius: tokens.$space-1; + background: var(--color-fill-3); + } +} +.mobile-change-review-sheet { + .mobile-change-review__file-heading { + display: flex; + align-items: center; + gap: tokens.$space-2; + padding: tokens.$space-2 tokens.$space-3; + min-width: 0; + } + .mobile-change-review__file-toggle { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: tokens.$space-1; + border: 0; + padding: tokens.$space-1 0; + text-align: start; + background: transparent; + color: var(--color-text-1); + cursor: pointer; + &:disabled { + cursor: default; + } + } + .mobile-change-review__filename, + .mobile-change-review__directory { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mobile-change-review__filename { + font-size: var(--mobile-type-secondary-size); + font-weight: var(--mobile-type-emphasis-weight); + } + .mobile-change-review__directory { + font-size: var(--mobile-type-caption-size); + color: var(--color-text-3); + } + .mobile-change-review__file-actions { + order: 2; + border-top: 1px solid var(--color-border-1); + > summary { + display: flex; + justify-content: flex-end; + align-items: center; + min-height: tokens.$touch-target; + padding-inline: tokens.$space-3; + list-style: none; + cursor: pointer; + color: var(--color-text-3); + &::-webkit-details-marker { + display: none; + } + } + > .mobile-change-review__toolbar { + padding: 0 tokens.$space-3 tokens.$space-2; + } + } + .mobile-change-review__editor--diff:has(.mobile-patch-diff) { + max-height: none; + } +} +.mobile-patch-diff { + min-width: 0; + font-size: var(--mobile-type-caption-size); + &__hunk { + border-top: 1px solid var(--color-border-1); + } + &__hunk > summary { + display: flex; + align-items: center; + gap: tokens.$space-2; + min-height: tokens.$touch-target; + padding: tokens.$space-2 tokens.$space-3; + list-style: none; + color: var(--color-text-3); + cursor: pointer; + &::-webkit-details-marker { + display: none; + } + > span:first-of-type { + flex: 1; + min-width: 0; + } + } + &__hunk:not([open]) > summary > svg { + transform: rotate(-90deg); + } + &__code { + border-top: 1px solid var(--color-border-1); + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--mobile-type-code-size); + line-height: var(--mobile-type-code-leading); + padding-block: tokens.$space-1; + overscroll-behavior-x: contain; + } + &__line { + display: grid; + grid-template-columns: 5ch 2ch minmax(0, 1fr); + width: max-content; + min-width: 100%; + &.is-added { + background: var(--diff-added-bg); + } + &.is-deleted { + background: var(--diff-deleted-bg); + } + > code { + white-space: pre; + padding-inline-end: tokens.$space-3; + font: inherit; + color: var(--color-text-1); + background: none; + } + } + &.is-wrapped &__line { + width: 100%; + } + &.is-wrapped &__line > code { + white-space: pre-wrap; + overflow-wrap: anywhere; + } + &__number { + color: var(--color-text-3); + text-align: right; + padding-inline: tokens.$space-2 tokens.$space-1; + font-variant-numeric: tabular-nums; + user-select: none; + background: var(--color-fill-1); + } + &__sign { + text-align: center; + color: var(--color-text-3); + user-select: none; + } + &__newline { + color: var(--color-text-3); + font-family: inherit; + font-size: var(--mobile-type-caption-size); + } + &__notice { + padding: tokens.$space-2 tokens.$space-3; + color: var(--color-text-3); + font-size: var(--mobile-type-caption-size); + } + &__more { + width: 100%; + border-radius: 0; + } + summary:focus-visible, + &__code:focus-visible { + outline: 2px solid var(--color-primary-6); + outline-offset: -2px; + } +} diff --git a/src/modules/MobileRemote/components/changes/mobileChangeReviewState.scss b/src/modules/MobileRemote/components/changes/mobileChangeReviewState.scss new file mode 100644 index 0000000000..179ff6e260 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/mobileChangeReviewState.scss @@ -0,0 +1,75 @@ +@use "../../mobileUiTokens" as tokens; + +.mobile-change-state { + --mobile-change-state-touch: #{tokens.$touch-target}; + --mobile-change-state-space: #{tokens.$space-4}; + --mobile-change-state-radius: #{tokens.$space-3}; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: tokens.$space-3; + width: 100%; + min-width: 0; + min-height: tokens.$touch-target * 4; + padding: tokens.$space-4 * 2 tokens.$space-4; + border: 1px solid var(--color-border-2); + border-radius: tokens.$space-4; + background: var(--color-bg-2); + color: var(--color-text-3); + font-size: var(--mobile-type-secondary-size); + line-height: var(--mobile-type-body-leading); + text-align: center; + + &__symbol { + display: flex; + align-items: center; + justify-content: center; + width: tokens.$touch-target; + height: tokens.$touch-target; + flex-shrink: 0; + border-radius: 50%; + background: var(--color-fill-1); + } + + &__message { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } + + &__retry { + flex-shrink: 0; + color: var(--color-text-1); + } + + &__skeleton { + display: flex; + flex-direction: column; + gap: tokens.$space-4; + width: 100%; + } + + &--compact { + flex-direction: row; + flex-wrap: wrap; + gap: tokens.$space-2; + min-height: tokens.$touch-target * 2; + padding: tokens.$space-3; + border: 0; + border-top: 1px solid var(--color-border-2); + border-radius: 0; + text-align: start; + + .mobile-change-state__symbol { + width: tokens.$space-4 + tokens.$space-2; + height: tokens.$space-4 + tokens.$space-2; + background: transparent; + } + + .mobile-change-state__message { + flex: 1; + } + } +} diff --git a/src/modules/MobileRemote/components/changes/parseReviewPatch.test.ts b/src/modules/MobileRemote/components/changes/parseReviewPatch.test.ts new file mode 100644 index 0000000000..0245e53354 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/parseReviewPatch.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { parseReviewPatch } from "./parseReviewPatch"; + +describe("review patch presentation boundary", () => { + it("separates file envelopes and hunks from code with real old/new coordinates", () => { + const hunks = parseReviewPatch( + "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -12,3 +12,4 @@ function\n context\n-old\n+new\n+added\n tail\n" + )!; + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ + additions: 2, + deletions: 1, + oldStart: 12, + newStart: 12, + }); + expect( + hunks[0].lines.map(({ kind, text, oldLine, newLine }) => [ + kind, + text, + oldLine, + newLine, + ]) + ).toEqual([ + ["context", "context", 12, 12], + ["deleted", "old", 13, null], + ["added", "new", null, 13], + ["added", "added", null, 14], + ["context", "tail", 14, 15], + ]); + }); + it("does not hide real code that resembles file headers", () => { + const hunk = parseReviewPatch( + "--- a\n+++ b\n@@ -1 +1 @@\n---old\n+++new\n" + )![0]; + expect(hunk.lines.map((line) => line.text)).toEqual(["--old", "++new"]); + }); + it("handles added/deleted files, CRLF, and missing final newlines", () => { + const added = parseReviewPatch( + "--- /dev/null\r\n+++ a\r\n@@ -0,0 +1,2 @@\r\n+one\r\n+two\r\n\\ No newline at end of file\r\n" + )![0]; + expect(added.lines.map((line) => line.newLine)).toEqual([1, 2]); + expect(added.lines[1].noNewline).toBe(true); + expect( + parseReviewPatch("--- a\n+++ /dev/null\n@@ -7,1 +0,0 @@\n-old")![0] + .lines[0].oldLine + ).toBe(7); + }); + it("keeps distant hunks separate without materializing missing lines", () => { + const hunks = parseReviewPatch( + "@@ -1 +1 @@\n-a\n+b\n@@ -900000 +900000 @@\n-x\n+y\n" + )!; + expect(hunks.map((hunk) => hunk.lines.length)).toEqual([2, 2]); + expect(hunks[1].lines[1].newLine).toBe(900000); + }); + it.each([ + "Binary files a and b differ\n", + "rename from a\nrename to b\n", + "@@ -1,2 +1,2 @@\n-one\n+two\n", + "@@ -1 +1 @@\n-one\n+two\n+extra\n", + "@@ -0 +0 @@\n-x\n+y\n", + "@@ -9007199254740992 +1 @@\n-x\n+y\n", + "*** Begin Patch\n*** Add File: a\n+x\n*** End Patch", + ])( + "preserves unsupported or incomplete input via explicit raw fallback: %s", + (patch) => { + expect(parseReviewPatch(patch)).toBeNull(); + } + ); + it("bounds parsing work before allocating a massive line model", () => { + expect(parseReviewPatch("x".repeat(524289))).toBeNull(); + expect(parseReviewPatch("\n".repeat(10001))).toBeNull(); + }); +}); diff --git a/src/modules/MobileRemote/components/changes/parseReviewPatch.ts b/src/modules/MobileRemote/components/changes/parseReviewPatch.ts new file mode 100644 index 0000000000..8e2e135f39 --- /dev/null +++ b/src/modules/MobileRemote/components/changes/parseReviewPatch.ts @@ -0,0 +1,97 @@ +export interface ReviewPatchLine { + kind: "context" | "added" | "deleted"; + text: string; + oldLine: number | null; + newLine: number | null; + noNewline?: boolean; +} +export interface ReviewPatchHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: ReviewPatchLine[]; + additions: number; + deletions: number; +} + +/** Presentation projection only: preserve patch order and coordinates, never + * reconstruct missing whole-file snapshots or sum overlapping edits as net. */ +export function parseReviewPatch(patch: string): ReviewPatchHunk[] | null { + if (patch.length > 524288) return null; + const input = patch.replace(/\r\n/g, "\n").split("\n"); + if (input.length > 10000) return null; + const hunks: ReviewPatchHunk[] = []; + let hunk: ReviewPatchHunk | undefined; + let oldUsed = 0; + let newUsed = 0; + for (let index = 0; index < input.length; index++) { + const line = input[index]; + if (index === input.length - 1 && line === "") break; + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$/.exec( + line + ); + if (header) { + // A truncated hunk must not be silently joined with a later edit. + if (hunk && (oldUsed !== hunk.oldCount || newUsed !== hunk.newCount)) + return null; + const [oldStart, oldCount, newStart, newCount] = [ + Number(header[1]), + Number(header[2] ?? 1), + Number(header[3]), + Number(header[4] ?? 1), + ]; + if ( + ![oldStart, oldCount, newStart, newCount].every(Number.isSafeInteger) || + !Number.isSafeInteger(oldStart + oldCount) || + !Number.isSafeInteger(newStart + newCount) || + (oldCount > 0 && oldStart === 0) || + (newCount > 0 && newStart === 0) + ) + return null; + hunk = { + oldStart, + oldCount, + newStart, + newCount, + lines: [], + additions: 0, + deletions: 0, + }; + hunks.push(hunk); + oldUsed = 0; + newUsed = 0; + continue; + } + if (line === "\\ No newline at end of file" && hunk?.lines.length) { + hunk.lines[hunk.lines.length - 1].noNewline = true; + continue; + } + if (hunk && (oldUsed < hunk.oldCount || newUsed < hunk.newCount)) { + const marker = line[0]; + if (marker !== "+" && marker !== "-" && marker !== " ") return null; + const added = marker === "+"; + const deleted = marker === "-"; + hunk.lines.push({ + kind: added ? "added" : deleted ? "deleted" : "context", + text: line.slice(1), + oldLine: added ? null : hunk.oldStart + oldUsed++, + newLine: deleted ? null : hunk.newStart + newUsed++, + }); + if (added) hunk.additions++; + if (deleted) hunk.deletions++; + if (oldUsed > hunk.oldCount || newUsed > hunk.newCount) return null; + continue; + } + if ( + /^(diff --git |index |--- |\+\+\+ |new file mode |deleted file mode |old mode |new mode |similarity index |rename from |rename to )/.test( + line + ) + ) + continue; + return null; + } + if (!hunk || oldUsed !== hunk.oldCount || newUsed !== hunk.newCount) + return null; + return hunks; +} diff --git a/src/modules/MobileRemote/components/changes/useChangeReview.test.ts b/src/modules/MobileRemote/components/changes/useChangeReview.test.ts new file mode 100644 index 0000000000..ba16782b3c --- /dev/null +++ b/src/modules/MobileRemote/components/changes/useChangeReview.test.ts @@ -0,0 +1,322 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { MobileRpcClient } from "../../connection/mobileRpcClient"; +import { + type ChangeScope, + type Review, + useChangeReview, +} from "./useChangeReview"; + +vi.mock("../../platform", async () => { + const { createBrowserMobileRemotePlatform } = + await import("../../platform/browser"); + const platform = createBrowserMobileRemotePlatform(); + return { useMobileRemotePlatform: () => platform }; +}); + +interface Props { + client: MobileRpcClient | null; + sessionId: string; + roundId: string | null; + scope: ChangeScope; + enabled: boolean; + revision: string; + filePath?: string; +} + +const review = (path?: string): Review => ({ + complete: true, + files: path + ? [ + { + path, + additions: 1, + deletions: 0, + patches: [], + before: null, + after: null, + availability: "patch_only", + }, + ] + : [], +}); +const deferred = () => { + let resolve!: (value: Review) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + +let root: ReturnType; +let host: HTMLDivElement; +let current: ReturnType; +let props: Props; +const call = vi.fn(); + +function Probe(value: Props) { + const result = useChangeReview( + value.client, + value.sessionId, + value.roundId, + value.scope, + value.enabled, + value.revision, + value.filePath + ); + React.useLayoutEffect(() => { + current = result; + }, [result]); + return React.createElement( + "output", + null, + result.error ? "error" : result.value ? "success" : "pending" + ); +} + +async function render(next: Partial = {}) { + props = { ...props, ...next }; + await act(async () => root.render(React.createElement(Probe, props))); +} +async function advance(ms = 250) { + await act(async () => vi.advanceTimersByTimeAsync(ms)); +} + +beforeEach(() => { + vi.useFakeTimers(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + call.mockReset(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + props = { + client: { call } as unknown as MobileRpcClient, + sessionId: "session-a", + roundId: "round-one", + scope: "turn", + enabled: true, + revision: "revision-one", + }; +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.useRealTimers(); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe("useChangeReview", () => { + it.each(["file.ts", undefined])( + "moves failed retry immediately to pending and resolves %s without duplicate calls", + async (path) => { + const retry = deferred(); + call + .mockRejectedValueOnce(new Error("offline")) + .mockReturnValueOnce(retry.promise); + await render(); + expect(host.textContent).toBe("pending"); + await advance(249); + expect(call).not.toHaveBeenCalled(); + await advance(1); + expect(host.textContent).toBe("error"); + + await act(async () => { + for (let index = 0; index < 5; index++) current.retry(); + }); + expect(host.textContent).toBe("pending"); + expect(current.error).toBeUndefined(); + expect(current.value).toBeUndefined(); + expect(vi.getTimerCount()).toBe(1); + expect(call).toHaveBeenCalledTimes(1); + + await advance(); + expect(call).toHaveBeenCalledTimes(2); + const signal = call.mock.calls[1][2] as AbortSignal; + await act(async () => { + current.retry(); + current.retry(); + }); + await advance(1000); + expect(call).toHaveBeenCalledTimes(2); + expect(signal.aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); + + await act(async () => retry.resolve(review(path))); + expect(host.textContent).toBe("success"); + expect(current.value).toEqual(review(path)); + expect(current.error).toBeUndefined(); + await act(async () => current.retry()); + await advance(1000); + expect(call).toHaveBeenCalledTimes(2); + } + ); + + it.each>([ + { sessionId: "session-b" }, + { roundId: "round-two" }, + { scope: "workspace" }, + { revision: "revision-two" }, + { filePath: "other.ts" }, + ])( + "aborts and isolates a pending response on scope change %j", + async (next) => { + const previous = deferred(); + const fresh = deferred(); + call + .mockReturnValueOnce(previous.promise) + .mockReturnValueOnce(fresh.promise); + await render(); + await advance(); + const previousSignal = call.mock.calls[0][2] as AbortSignal; + await render(next); + expect(previousSignal.aborted).toBe(true); + expect(current.value).toBeUndefined(); + await advance(); + await act(async () => fresh.resolve(review("fresh.ts"))); + await act(async () => previous.resolve(review("stale.ts"))); + expect(current.value).toEqual(review("fresh.ts")); + expect(current.error).toBeUndefined(); + expect(call).toHaveBeenCalledTimes(2); + } + ); + + it("does not expose the previous client's result or late failure", async () => { + const previous = deferred(); + const freshCall = vi.fn().mockResolvedValue(review("fresh.ts")); + call.mockReturnValue(previous.promise); + await render(); + await advance(); + await render({ client: { call: freshCall } as unknown as MobileRpcClient }); + expect((call.mock.calls[0][2] as AbortSignal).aborted).toBe(true); + expect(current.value).toBeUndefined(); + await advance(); + await act(async () => previous.reject(new Error("old connection failed"))); + expect(current.value).toEqual(review("fresh.ts")); + expect(current.error).toBeUndefined(); + expect(freshCall).toHaveBeenCalledTimes(1); + }); + + it("coalesces revision bursts and clears previous successful data during refresh", async () => { + call.mockResolvedValue(review("before.ts")); + await render(); + await advance(); + expect(current.value).toEqual(review("before.ts")); + await render({ revision: "two" }); + expect(current.value).toBeUndefined(); + await advance(100); + await render({ revision: "three" }); + await advance(100); + await render({ revision: "four" }); + expect(call).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(1); + call.mockResolvedValue(review("after.ts")); + await advance(); + expect(call).toHaveBeenCalledTimes(2); + expect(current.value).toEqual(review("after.ts")); + await advance(60_000); + expect(call).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("allows a subsequent retry only after the current attempt has failed", async () => { + call + .mockRejectedValueOnce(new Error("offline")) + .mockRejectedValueOnce(new Error("still offline")) + .mockResolvedValueOnce(review("recovered.ts")); + await render(); + await advance(); + expect(current.error).toBe(true); + await act(async () => current.retry()); + expect(current.error).toBeUndefined(); + await advance(); + expect(current.error).toBe(true); + await act(async () => current.retry()); + expect(current.error).toBeUndefined(); + await advance(); + expect(current.value).toEqual(review("recovered.ts")); + expect(call).toHaveBeenCalledTimes(3); + }); + + it("releases successful snapshots when a viewer is disabled", async () => { + call.mockResolvedValue(review("large-file.ts")); + await render(); + await advance(); + expect(current.value).toEqual(review("large-file.ts")); + await render({ enabled: false }); + expect(current.value).toBeUndefined(); + expect(current.error).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + await render({ enabled: true }); + expect(current.value).toBeUndefined(); + await advance(); + expect(call).toHaveBeenCalledTimes(2); + }); + + it.each([0, 250])( + "cancels disabled work after %i ms and reloads once when enabled returns", + async (elapsed) => { + const previous = deferred(); + call.mockReturnValue(previous.promise); + await render(); + await advance(elapsed); + const signal = call.mock.calls[0]?.[2] as AbortSignal | undefined; + await render({ enabled: false }); + expect(vi.getTimerCount()).toBe(0); + if (signal) expect(signal.aborted).toBe(true); + const callsBefore = call.mock.calls.length; + await act(async () => { + current.retry(); + previous.resolve(review("stale.ts")); + }); + await advance(60_000); + expect(call).toHaveBeenCalledTimes(callsBefore); + expect(current.value).toBeUndefined(); + call.mockResolvedValue(review("visible.ts")); + await render({ enabled: true }); + await advance(); + expect(call).toHaveBeenCalledTimes(callsBefore + 1); + expect(current.value).toEqual(review("visible.ts")); + } + ); + + it.each([0, 250])( + "cleans timers and aborts on unmount after %i ms", + async (elapsed) => { + const pending = deferred(); + call.mockReturnValue(pending.promise); + await render(); + await advance(elapsed); + const signal = call.mock.calls[0]?.[2] as AbortSignal | undefined; + await act(async () => root.render(null)); + expect(vi.getTimerCount()).toBe(0); + if (signal) expect(signal.aborted).toBe(true); + const count = call.mock.calls.length; + await act(async () => pending.resolve(review("stale.ts"))); + await advance(60_000); + expect(call).toHaveBeenCalledTimes(count); + expect(host.textContent).toBe(""); + } + ); + + it("does not request without a client, and rejects malformed success as error", async () => { + await render({ client: null }); + await act(async () => current.retry()); + await advance(1000); + expect(call).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + call.mockResolvedValue({ files: [] }); + await render({ client: { call } as unknown as MobileRpcClient }); + await advance(); + expect(current.error).toBe(true); + expect(current.value).toBeUndefined(); + }); +}); diff --git a/src/modules/MobileRemote/components/changes/useChangeReview.ts b/src/modules/MobileRemote/components/changes/useChangeReview.ts new file mode 100644 index 0000000000..ab64e1c9ec --- /dev/null +++ b/src/modules/MobileRemote/components/changes/useChangeReview.ts @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; + +import type { MobileRpcClient } from "../../connection/mobileRpcClient"; +import { useMobileRemotePlatform } from "../../platform"; + +export type ChangeScope = "turn" | "session" | "workspace"; +export interface ChangeFile { + path: string; + additions: number | null; + deletions: number | null; + patches: string[]; + before: string | null; + after: string | null; + availability: string; +} +export interface Review { + files: ChangeFile[]; + complete: boolean; +} +export function validateReview(value: unknown): Review { + const result = value as Review; + if ( + !result || + typeof result.complete !== "boolean" || + !Array.isArray(result.files) || + result.files.length > 500 || + !result.files.every( + (f) => + f && + typeof f.path === "string" && + f.path.length > 0 && + [f.additions, f.deletions].every( + (n) => n === null || (Number.isSafeInteger(n) && n >= 0) + ) && + [f.before, f.after].every( + (s) => s === null || (typeof s === "string" && s.length <= 524288) + ) && + Array.isArray(f.patches) && + f.patches.every((p) => typeof p === "string") && + typeof f.availability === "string" + ) || + JSON.stringify(value).length > 3 * 1024 * 1024 + ) + throw new Error("Invalid change review"); + return result; +} + +export function useChangeReview( + client: MobileRpcClient | null, + sessionId: string, + roundId: string | null, + scope: ChangeScope, + enabled: boolean, + revision: string, + filePath?: string +) { + const key = JSON.stringify([sessionId, roundId, scope, revision, filePath]); + const { runtime } = useMobileRemotePlatform(); + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState<{ + client: MobileRpcClient; + key: string; + attempt: number; + value?: Review; + error?: boolean; + } | null>(null); + // File viewers leave the viewport or collapse; do not retain their large + // snapshots for the lifetime of the entire review panel. + if (!enabled && state !== null) setState(null); + useEffect(() => { + if (!client || !enabled) return; + const controller = new AbortController(); + const timer = runtime.setTimeout(() => { + void client + .call( + "session/changes", + { sessionId, roundId, scope, filePath }, + controller.signal + ) + .then(validateReview) + .then((value) => { + if (!controller.signal.aborted) + setState({ client, key, attempt, value }); + }) + .catch(() => { + if (!controller.signal.aborted) + setState({ client, key, attempt, error: true }); + }); + }, 250); + return () => { + runtime.clearTimeout(timer); + controller.abort(); + }; + }, [ + client, + enabled, + key, + sessionId, + roundId, + scope, + filePath, + attempt, + runtime, + ]); + const current = + state?.client === client && state.key === key && state.attempt === attempt + ? state + : null; + return { + value: current?.value, + error: current?.error, + retry: () => { + if (!client || !enabled || !current?.error) return; + // Invalidate the failed result immediately. Repeated taps from the same + // render may enqueue updates together; only the first starts a retry. + setAttempt((pendingAttempt) => + pendingAttempt === attempt ? attempt + 1 : pendingAttempt + ); + }, + }; +} diff --git a/src/modules/MobileRemote/components/composer/MobileComposer.test.ts b/src/modules/MobileRemote/components/composer/MobileComposer.test.ts index afcf6f8afc..35fda8fc7a 100644 --- a/src/modules/MobileRemote/components/composer/MobileComposer.test.ts +++ b/src/modules/MobileRemote/components/composer/MobileComposer.test.ts @@ -23,17 +23,26 @@ vi.mock("react-i18next", () => ({ }), })); +const voiceTest = vi.hoisted(() => ({ + start: vi.fn(), + onError: undefined as + | undefined + | ((error: { code: string; message: string }) => void), +})); vi.mock("@src/hooks/voice", () => ({ - useVoiceInput: () => ({ - isRecording: false, - isSupported: true, - liveTranscript: "", - elapsedSeconds: 0, - start: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), - toggle: vi.fn(), - }), + useVoiceInput: (options: { onError?: typeof voiceTest.onError }) => { + voiceTest.onError = options.onError; + return { + isRecording: false, + isSupported: true, + liveTranscript: "", + elapsedSeconds: 0, + start: voiceTest.start, + stop: vi.fn(), + cancel: vi.fn(), + toggle: vi.fn(), + }; + }, })); ( @@ -50,6 +59,27 @@ afterEach(() => { host?.remove(); root = null; host = null; + voiceTest.start.mockClear(); + voiceTest.onError = undefined; +}); + +it("retries microphone permission from the shared primary Button without sending a message", async () => { + const onSend = vi.fn(); + await renderComposer(onSend); + await act(async () => + voiceTest.onError?.({ + code: "permission-denied", + message: "Permission denied", + }) + ); + const retry = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "Try again" + )!; + expect(retry).not.toBeUndefined(); + expect(retry.classList.contains("button")).toBe(true); + await act(async () => retry.click()); + expect(voiceTest.start).toHaveBeenCalledOnce(); + expect(onSend).not.toHaveBeenCalled(); }); async function renderComposer( diff --git a/src/modules/MobileRemote/components/composer/MobileComposer.tsx b/src/modules/MobileRemote/components/composer/MobileComposer.tsx index c713cda1c8..ae94beae3b 100644 --- a/src/modules/MobileRemote/components/composer/MobileComposer.tsx +++ b/src/modules/MobileRemote/components/composer/MobileComposer.tsx @@ -23,14 +23,18 @@ import { resolveVoicePermissionErrorMessage } from "@src/hooks/voice/voicePermis import type { MobileSendAttachment } from "@src/modules/MobileRemote/connection/types"; import { MobileComposerAttachmentButton } from "./MobileComposerAttachmentButton"; +import { useMobileComposerDraft } from "./MobileComposerDraftContext"; import { MobileComposerImagePreview } from "./MobileComposerImagePreview"; import { MobileModelPicker } from "./MobileModelPicker"; import type { MobileModelPickerProps } from "./MobileModelPicker"; +import { MOBILE_DRAFT_TEXT_LIMIT } from "./mobileComposerDraftStore"; import { useMobileComposerImages } from "./useMobileComposerImages"; const MOBILE_COMPOSER_EDITOR_MIN_HEIGHT = 36; export interface MobileComposerProps { + /** Account/endpoint are owned by the provider; this identifies desktop + session. */ + draftScope?: string; disabled?: boolean; disabledReason?: string; statusMessage?: string; @@ -52,27 +56,47 @@ export function MobileComposer({ statusTone = "neutral", onSend, modelPicker, + draftScope, }: MobileComposerProps) { const { t } = useTranslation("sessions"); const { t: tCommon } = useTranslation("common"); const { t: tVoice } = useTranslation("sessions", { keyPrefix: "input" }); - const [draft, setDraft] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(); + const { handle: draftHandle, snapshot } = useMobileComposerDraft(draftScope); + const { text: draft, submitting, submitError } = snapshot; + const setDraft = useCallback( + (value: string | ((text: string) => string)) => { + draftHandle.update((current) => { + const text = typeof value === "function" ? value(current.text) : value; + return text.length <= MOBILE_DRAFT_TEXT_LIMIT + ? { ...current, text } + : { + ...current, + submitError: t("chat.draftTooLong", { + defaultValue: "Draft is too long (maximum {{max}} characters)", + max: MOBILE_DRAFT_TEXT_LIMIT, + }), + }; + }); + }, + [draftHandle, t] + ); const [voiceError, setVoiceError] = useState(); const [voicePermissionSheetOpen, setVoicePermissionSheetOpen] = useState(false); - const handleVoiceCommit = useCallback((transcript: string) => { - const trimmed = transcript.trim(); - if (!trimmed) return; - setVoiceError(undefined); - setDraft((existing) => { - const separator = - existing.length === 0 || /\s$/.test(existing) ? "" : " "; - return `${existing}${separator}${trimmed}`; - }); - }, []); + const handleVoiceCommit = useCallback( + (transcript: string) => { + const trimmed = transcript.trim(); + if (!trimmed) return; + setVoiceError(undefined); + setDraft((existing) => { + const separator = + existing.length === 0 || /\s$/.test(existing) ? "" : " "; + return `${existing}${separator}${trimmed}`; + }); + }, + [setDraft] + ); const handleVoiceError = useCallback( (err: VoiceInputError) => { @@ -107,29 +131,46 @@ export function MobileComposer({ voice.start(); }, [voice]); - const attachments = useMobileComposerImages(); + const attachments = useMobileComposerImages(draftHandle); + const { toSendAttachments } = attachments; const handleSend = useCallback(async () => { - if (disabled || submitting || attachments.processing) return; - const trimmed = draft.trim(); - const pendingAttachments = attachments.toSendAttachments(); + const submitted = draftHandle.getSnapshot(); + if (disabled || submitted.submitting || submitted.processing || !onSend) + return; + const trimmed = submitted.text.trim(); + const pendingAttachments = toSendAttachments(); if (!trimmed && pendingAttachments.length === 0) return; - setSubmitting(true); - setSubmitError(undefined); + const entry = draftHandle.capture(); + draftHandle.updateIfCurrent(entry, (current) => ({ + ...current, + submitting: true, + submitError: undefined, + })); try { - await onSend?.(trimmed, pendingAttachments); - setDraft((current) => (current === draft ? "" : current)); - attachments.clearImages(); + await onSend(trimmed, pendingAttachments); + const sentImages = new Set(submitted.images.map((image) => image.id)); + draftHandle.updateIfCurrent(entry, (current) => ({ + ...current, + text: + current.textRevision === submitted.textRevision ? "" : current.text, + images: current.images.filter((image) => !sentImages.has(image.id)), + })); } catch (error) { - setSubmitError( - error instanceof Error - ? error.message - : t("chat.sendFailed", "Message could not be sent") - ); + draftHandle.updateIfCurrent(entry, (current) => ({ + ...current, + submitError: + error instanceof Error + ? error.message + : t("chat.sendFailed", "Message could not be sent"), + })); } finally { - setSubmitting(false); + draftHandle.updateIfCurrent(entry, (current) => ({ + ...current, + submitting: false, + })); } - }, [attachments, disabled, draft, onSend, submitting, t]); + }, [toSendAttachments, disabled, draftHandle, onSend, t]); const visibleStatus = attachments.error ?? @@ -194,6 +235,7 @@ export function MobileComposer({ editorSlot={