diff --git a/.github/workflows/android-play-release.yml b/.github/workflows/android-play-release.yml index cf2c8157..005aa35e 100644 --- a/.github/workflows/android-play-release.yml +++ b/.github/workflows/android-play-release.yml @@ -17,6 +17,7 @@ jobs: SCCACHE_REGION: auto SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/android-release-prep + SCCACHE_LOG: info AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -92,6 +93,7 @@ jobs: SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/android SCCACHE_LOG: info + SCCACHE_ERROR_LOG: ${{ runner.temp }}/sccache-android.log AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -152,7 +154,6 @@ jobs: - name: Prime sccache run: | - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-android.log" rm -f "$SCCACHE_ERROR_LOG" sccache --stop-server || true sccache --start-server @@ -212,6 +213,8 @@ jobs: JAVA_HOME: ${{ env.JAVA_HOME }} ANDROID_SDK_ROOT: ${{ env.ANDROID_HOME }} ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/30.0.14904198 + CARGO_BUILD_JOBS: "2" + GRADLE_MAX_WORKERS: "2" RUSTC_WRAPPER: sccache LITTER_UPLOAD_STORE_FILE: ${{ env.LITTER_UPLOAD_STORE_FILE }} LITTER_UPLOAD_STORE_PASSWORD: ${{ secrets.LITTER_UPLOAD_STORE_PASSWORD }} @@ -222,7 +225,6 @@ jobs: LITTER_VERSION_CODE_OVERRIDE: ${{ env.LITTER_VERSION_CODE_OVERRIDE }} run: | set -euo pipefail - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-android.log" trap 'status=$?; echo "==> sccache stats"; sccache --show-stats || true; if [ -f "$SCCACHE_ERROR_LOG" ]; then echo "==> sccache error log"; tail -200 "$SCCACHE_ERROR_LOG" || true; fi; exit $status' EXIT make play-release diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 7b342d87..666da3fb 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -30,6 +30,7 @@ jobs: SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/ios SCCACHE_LOG: info + SCCACHE_ERROR_LOG: ${{ runner.temp }}/sccache-ios.log AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -86,7 +87,6 @@ jobs: - name: Prime sccache run: | - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-ios.log" rm -f "$SCCACHE_ERROR_LOG" sccache --stop-server || true sccache --start-server @@ -162,6 +162,7 @@ jobs: - name: Upload to TestFlight env: + CARGO_BUILD_JOBS: "2" RUSTC_WRAPPER: sccache SCHEME: ${{ env.SCHEME }} APP_BUNDLE_ID: ${{ env.APP_BUNDLE_ID }} @@ -176,6 +177,5 @@ jobs: WAIT_FOR_PROCESSING: ${{ env.WAIT_FOR_PROCESSING }} run: | set -euo pipefail - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-ios.log" trap 'status=$?; echo "==> sccache stats"; sccache --show-stats || true; if [ -f "$SCCACHE_ERROR_LOG" ]; then echo "==> sccache error log"; tail -200 "$SCCACHE_ERROR_LOG" || true; fi; exit $status' EXIT make testflight diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index d34eda3b..ee02afbb 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -249,6 +249,8 @@ jobs: fi tar -xzf "$SHARED_PREP_ROOT/generated-mobile-sources.tgz" -C . + mkdir -p .build-stamps + touch .build-stamps/sync .build-stamps/bindings-swift - name: Build Rust for iOS simulator env: diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index bf1530ed..7397e5a9 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -86,6 +86,7 @@ jobs: SCCACHE_REGION: auto SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/mobile-release-prep + SCCACHE_LOG: info AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -198,6 +199,7 @@ jobs: SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/android SCCACHE_LOG: info + SCCACHE_ERROR_LOG: ${{ runner.temp }}/sccache-android.log AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -258,7 +260,6 @@ jobs: - name: Prime sccache run: | - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-android.log" rm -f "$SCCACHE_ERROR_LOG" sccache --stop-server || true sccache --start-server @@ -318,6 +319,8 @@ jobs: JAVA_HOME: ${{ env.JAVA_HOME }} ANDROID_SDK_ROOT: ${{ env.ANDROID_HOME }} ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/30.0.14904198 + CARGO_BUILD_JOBS: "2" + GRADLE_MAX_WORKERS: "2" RUSTC_WRAPPER: sccache LITTER_UPLOAD_STORE_FILE: ${{ env.LITTER_UPLOAD_STORE_FILE }} LITTER_UPLOAD_STORE_PASSWORD: ${{ secrets.LITTER_UPLOAD_STORE_PASSWORD }} @@ -328,7 +331,6 @@ jobs: LITTER_VERSION_CODE_OVERRIDE: ${{ env.LITTER_VERSION_CODE_OVERRIDE }} run: | set -euo pipefail - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-android.log" trap 'status=$?; echo "==> sccache stats"; sccache --show-stats || true; if [ -f "$SCCACHE_ERROR_LOG" ]; then echo "==> sccache error log"; tail -200 "$SCCACHE_ERROR_LOG" || true; fi; exit $status' EXIT make play-release @@ -365,6 +367,7 @@ jobs: SCCACHE_S3_USE_SSL: "true" SCCACHE_S3_KEY_PREFIX: ci/ios SCCACHE_LOG: info + SCCACHE_ERROR_LOG: ${{ runner.temp }}/sccache-ios.log AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }} @@ -421,7 +424,6 @@ jobs: - name: Prime sccache run: | - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-ios.log" rm -f "$SCCACHE_ERROR_LOG" sccache --stop-server || true sccache --start-server @@ -441,6 +443,8 @@ jobs: fi tar -xzf "$SHARED_PREP_ROOT/generated-mobile-sources.tgz" -C . + mkdir -p .build-stamps + touch .build-stamps/sync .build-stamps/bindings-swift - name: Decode App Store Connect API key env: @@ -502,6 +506,7 @@ jobs: - name: Upload to TestFlight env: + CARGO_BUILD_JOBS: "2" RUSTC_WRAPPER: sccache SCHEME: ${{ env.SCHEME }} APP_BUNDLE_ID: ${{ env.APP_BUNDLE_ID }} @@ -516,6 +521,5 @@ jobs: WAIT_FOR_PROCESSING: ${{ env.WAIT_FOR_PROCESSING }} run: | set -euo pipefail - export SCCACHE_ERROR_LOG="$RUNNER_TEMP/sccache-ios.log" trap 'status=$?; echo "==> sccache stats"; sccache --show-stats || true; if [ -f "$SCCACHE_ERROR_LOG" ]; then echo "==> sccache error log"; tail -200 "$SCCACHE_ERROR_LOG" || true; fi; exit $status' EXIT make testflight diff --git a/AGENTS.md b/AGENTS.md index 836af59b..14d44948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,12 +170,7 @@ Incremental policy: - For Xcode project regeneration, use `make xcgen` or `./apps/ios/scripts/regenerate-project.sh`. Do not run `xcodegen generate --spec project.yml --project Litter.xcodeproj` from inside `apps/ios`; that produces a nested `apps/ios/Litter.xcodeproj/Litter.xcodeproj`. - For Android emulator debugging, build with `make android-emulator-fast`, install with `adb -e install -r apps/android/app/build/outputs/apk/debug/app-debug.apk`, then launch with `adb -e shell am start -n com.sigkitten.litter.android/com.litter.android.MainActivity`. - Keep both runtimes available when validating shared Rust changes: boot a simulator with `xcrun simctl boot ` or through Simulator.app, and verify an emulator is visible with `adb devices -l`. -- Start the collector with `cargo run --manifest-path shared/rust-bridge/Cargo.toml -p mobile-log-collector -- serve --bind 0.0.0.0:8585 --data-dir /tmp/mobile-log-collector-e2e`. -- Query stored logs with either raw HTTP or the CLI: `curl 'http://127.0.0.1:8585/v1/query?limit=20'` or `cargo run --manifest-path shared/rust-bridge/Cargo.toml -p mobile-log-collector -- query --base-url http://127.0.0.1:8585 --device-id --pretty`. -- iOS simulator log config lives under the app container at `.../Library/Application Support/codex/log-spool/config.json`; use `xcrun simctl get_app_container booted com.sigkitten.litter data` to find the current container, then write the config there. -- Android log config lives at `files/codex-home/log-spool/config.json` inside the app sandbox; write it with `adb shell run-as com.sigkitten.litter.android ...`. When the collector runs on the host machine, use `http://10.0.2.2:8585` from the Android emulator and `http://127.0.0.1:8585` from the iOS simulator. -- A minimal debug config should set `enabled: true`, `collector_url`, `min_level: "DEBUG"`, and stable `device_id` / `device_name` fields so batches can be filtered reliably. -- After launch, verify upload by checking that `log-spool/pending` drains, then query the collector for the target `device_id`. If you need direct storage inspection, query `/tmp/mobile-log-collector-e2e/collector.sqlite3` and decompress batch files under `/tmp/mobile-log-collector-e2e/batches/...`. +- Mobile logs now stay local: use Xcode/device console for iOS, Logcat for Android, and normal Rust `tracing` output instead of a collector or spool directory. ## Coding Style & Naming Conventions - Swift style follows standard Xcode defaults: 4-space indentation, `UpperCamelCase` for types, `lowerCamelCase` for properties/functions. diff --git a/Makefile b/Makefile index f9f073b0..2c960bf2 100644 --- a/Makefile +++ b/Makefile @@ -28,8 +28,6 @@ ANDROID_RUST_PROFILE ?= android-dev ANDROID_RELEASE_ABIS ?= arm64-v8a,x86_64 HOST_ARCH := $(shell uname -m) ANDROID_EMULATOR_ABIS ?= $(if $(filter arm64 aarch64,$(HOST_ARCH)),arm64-v8a,x86_64) -LOG_COLLECTOR_BIND ?= 0.0.0.0:8585 -LOG_COLLECTOR_DATA_DIR ?= /tmp/mobile-log-collector-e2e # Auto-detect Android SDK/NDK/JDK paths (macOS defaults, overridable via env) ANDROID_SDK_ROOT ?= $(or $(ANDROID_HOME),$(wildcard $(HOME)/Library/Android/sdk)) @@ -52,11 +50,6 @@ endif PACKAGE_CARGO_ENV := CARGO_INCREMENTAL=0 -# Forward log collector env vars to xcodebuild as build settings -XCODE_EXTRA_SETTINGS := -ifdef LOG_COLLECTOR_URL - XCODE_EXTRA_SETTINGS += LOG_COLLECTOR_URL='$(LOG_COLLECTOR_URL)' -endif DEV_CARGO_ENV := env -u CARGO_INCREMENTAL PATCH_FILES := \ @@ -96,7 +89,6 @@ $(shell mkdir -p $(STAMPS)) android android-fast android-emulator-fast android-emulator-run android-device-run android-release android-debug android-install android-emulator-install \ rust-ios rust-ios-package rust-ios-device-fast rust-ios-sim-fast rust-android rust-check rust-test rust-host-dev \ bindings bindings-swift bindings-kotlin \ - log-collector \ sync patch unpatch xcgen ios-frameworks \ ios-build ios-build-sim ios-build-sim-fast ios-build-device ios-build-device-fast \ test test-rust test-ios test-android \ @@ -153,7 +145,7 @@ android-emulator-run: android-emulator-fast adb -s "$$EMU" shell am start -n $(ANDROID_PACKAGE)/$(ANDROID_ACTIVITY) android-device-run: android-fast @echo "==> Installing and launching on connected device..." - @DEVICE=$${ANDROID_DEVICE_SERIAL:-$$(adb devices | awk 'NR>1 && $$2=="device" && $$1 !~ /^emulator-/ {print $$1; exit}')} && \ + @DEVICE=$${ANDROID_DEVICE_SERIAL:-$$(adb devices | awk -F'\t' 'NR>1 && $$2=="device" && $$1 !~ /^emulator-/ {print $$1; exit}')} && \ if [ -z "$$DEVICE" ]; then echo "ERROR: no connected Android device found (set ANDROID_DEVICE_SERIAL= to override)"; exit 1; fi && \ echo "==> Using device $$DEVICE..." && \ INSTALL_OUTPUT=$$(adb -s "$$DEVICE" install -r $(ANDROID_APK) 2>&1) && \ @@ -205,11 +197,6 @@ rust-test: rust-host-dev: rust-check rust-test -log-collector: - @echo "==> Starting local mobile log collector on $(LOG_COLLECTOR_BIND)..." - @echo "==> Web tail UI will be available at /tail on that server." - @cd $(ROOT) && cargo run --manifest-path $(RUST_DIR)/Cargo.toml -p mobile-log-collector -- serve --bind $(LOG_COLLECTOR_BIND) --data-dir $(LOG_COLLECTOR_DATA_DIR) - rust-android: $(STAMP_RUST_ANDROID) $(STAMP_RUST_ANDROID): $(STAMP_SYNC) $(STAMP_BINDINGS_K) $(ANDROID_RUST_SOURCES) tools/scripts/build-android-rust.sh Makefile @echo "==> Building Rust for Android..." @@ -231,7 +218,6 @@ help: 'make android-emulator-run fast emulator build + install + launch on emulator' \ 'make android-device-run fast Android dev build + install + launch on connected device (override ANDROID_DEVICE_SERIAL; set ANDROID_REINSTALL_ON_SIGNATURE_MISMATCH=0 to keep installed app)' \ 'make android-release Android build using release Rust profile and multi-ABI output' \ - 'make log-collector start local log collector + web tail UI (override LOG_COLLECTOR_BIND / LOG_COLLECTOR_DATA_DIR)' \ 'make rust-check host cargo check for shared crates' \ 'make rust-test host cargo test for shared crates' @@ -304,7 +290,7 @@ ios-build-sim: verify-ios-project -scheme $(IOS_SCHEME) \ -configuration $(XCODE_CONFIG) \ -destination 'platform=iOS Simulator,name=$(IOS_SIM_DEVICE)' \ - $(XCODE_EXTRA_SETTINGS) build + build ios-build-sim-fast: verify-ios-project @echo "==> Building iOS ($(XCODE_CONFIG), fast simulator)..." @@ -312,7 +298,7 @@ ios-build-sim-fast: verify-ios-project -scheme $(IOS_SCHEME) \ -configuration $(XCODE_CONFIG) \ -destination 'platform=iOS Simulator,name=$(IOS_SIM_DEVICE)' \ - $(XCODE_EXTRA_SETTINGS) build + build ios-build-device: verify-ios-project @echo "==> Building iOS ($(XCODE_CONFIG), device)..." @@ -320,7 +306,7 @@ ios-build-device: verify-ios-project -scheme $(IOS_SCHEME) \ -configuration $(XCODE_CONFIG) \ -destination 'generic/platform=iOS' \ - $(XCODE_EXTRA_SETTINGS) build + build ios-build-device-fast: verify-ios-project @echo "==> Building iOS ($(XCODE_CONFIG), fast device)..." @@ -328,7 +314,7 @@ ios-build-device-fast: verify-ios-project -scheme $(IOS_SCHEME) \ -configuration $(XCODE_CONFIG) \ -destination 'generic/platform=iOS' \ - $(XCODE_EXTRA_SETTINGS) build + build ios-build: ios-build-sim @@ -338,7 +324,7 @@ android-debug: android-install: android-debug @echo "==> Installing APK to device..." - @DEVICE=$${ANDROID_DEVICE_SERIAL:-$$(adb devices | awk 'NR>1 && $$2=="device" && $$1 !~ /^emulator-/ {print $$1; exit}')} && \ + @DEVICE=$${ANDROID_DEVICE_SERIAL:-$$(adb devices | awk -F'\t' 'NR>1 && $$2=="device" && $$1 !~ /^emulator-/ {print $$1; exit}')} && \ if [ -z "$$DEVICE" ]; then echo "ERROR: no connected Android device found (set ANDROID_DEVICE_SERIAL= to override)"; exit 1; fi && \ echo "==> Using device $$DEVICE..." && \ INSTALL_OUTPUT=$$(adb -s "$$DEVICE" install -r $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk 2>&1) && \ diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index f473208a..fb3b6d2b 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -29,7 +29,6 @@ android { buildConfigField("boolean", "ENABLE_ON_DEVICE_BRIDGE", "true") buildConfigField("String", "RUNTIME_STARTUP_MODE", "\"hybrid\"") buildConfigField("String", "APP_RUNTIME_TRANSPORT", "\"app_bridge_rpc_transport\"") - buildConfigField("String", "LOG_COLLECTOR_URL", "\"${System.getenv("LOG_COLLECTOR_URL") ?: ""}\"") manifestPlaceholders["runtimeStartupMode"] = "hybrid" manifestPlaceholders["enableOnDeviceBridge"] = "true" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/apps/android/app/src/main/java/com/litter/android/MainActivity.kt b/apps/android/app/src/main/java/com/litter/android/MainActivity.kt index 8579e58d..5eda6cb4 100644 --- a/apps/android/app/src/main/java/com/litter/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/litter/android/MainActivity.kt @@ -133,7 +133,6 @@ class MainActivity : ComponentActivity() { archived = false, cwd = null, searchTerm = null, ), ) - appModel.refreshSnapshot() LLog.i("MainActivity", "Local in-process server connected") } catch (e: Exception) { LLog.w("MainActivity", "Local server failed", fields = mapOf("error" to e.message)) diff --git a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt index 6bc74e66..cc2ff301 100644 --- a/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt +++ b/apps/android/app/src/main/java/com/litter/android/state/AppModel.kt @@ -15,12 +15,21 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.atomic.AtomicLong import uniffi.codex_mobile_client.AppServerRpc +import uniffi.codex_mobile_client.AppSessionSummary import uniffi.codex_mobile_client.AppSnapshotRecord import uniffi.codex_mobile_client.AppStore import uniffi.codex_mobile_client.AppStoreSubscription import uniffi.codex_mobile_client.AppThreadSnapshot +import uniffi.codex_mobile_client.AppThreadStreamingDeltaKind import uniffi.codex_mobile_client.AppStoreUpdateRecord import uniffi.codex_mobile_client.DiscoveryBridge +import uniffi.codex_mobile_client.HydratedAssistantMessageData +import uniffi.codex_mobile_client.HydratedCommandExecutionData +import uniffi.codex_mobile_client.HydratedConversationItem +import uniffi.codex_mobile_client.HydratedConversationItemContent +import uniffi.codex_mobile_client.HydratedMcpToolCallData +import uniffi.codex_mobile_client.HydratedProposedPlanData +import uniffi.codex_mobile_client.HydratedReasoningData import uniffi.codex_mobile_client.HandoffManager import uniffi.codex_mobile_client.MessageParser import uniffi.codex_mobile_client.ModelListParams @@ -124,12 +133,11 @@ class AppModel private constructor(context: android.content.Context) { if (subscriptionJob?.isActive == true) return subscriptionJob = scope.launch { try { - refreshSnapshot() val subscription: AppStoreSubscription = store.subscribeUpdates() + refreshSnapshot() while (true) { try { val update: AppStoreUpdateRecord = subscription.nextUpdate() - LLog.d("AppModel", "AppStore update", fields = mapOf("update" to update::class.simpleName)) handleUpdate(update) } catch (e: Exception) { LLog.e("AppModel", "AppStore subscription loop failed", e) @@ -174,13 +182,16 @@ class AppModel private constructor(context: android.content.Context) { } } - private fun applySavedServerNames(snapshot: AppSnapshotRecord): AppSnapshotRecord { - val nameByServerId = SavedServerStore.load(appContext) + private fun loadSavedServerNames(): Map = + SavedServerStore.load(appContext) .mapNotNull { server -> val trimmed = server.name.trim() if (trimmed.isEmpty()) null else server.id to trimmed } .toMap() + + private fun applySavedServerNames(snapshot: AppSnapshotRecord): AppSnapshotRecord { + val nameByServerId = loadSavedServerNames() if (nameByServerId.isEmpty()) return snapshot return snapshot.copy( @@ -203,6 +214,15 @@ class AppModel private constructor(context: android.content.Context) { ) } + private fun applySavedServerName(summary: AppSessionSummary): AppSessionSummary { + val savedName = loadSavedServerNames()[summary.key.serverId] ?: return summary + return if (savedName != summary.serverDisplayName) { + summary.copy(serverDisplayName = savedName) + } else { + summary + } + } + suspend fun restartLocalServer() { val currentLocal = snapshot.value?.servers?.firstOrNull { it.isLocal } val serverId = currentLocal?.serverId ?: "local" @@ -250,7 +270,6 @@ class AppModel private constructor(context: android.content.Context) { ), ) } - refreshSnapshot() _lastError.value = null } catch (e: Exception) { _lastError.value = e.message @@ -419,8 +438,35 @@ class AppModel private constructor(context: android.content.Context) { private suspend fun handleUpdate(update: AppStoreUpdateRecord) { when (update) { - is AppStoreUpdateRecord.ThreadChanged -> refreshThreadSnapshot(update.key) - is AppStoreUpdateRecord.ThreadRemoved -> removeThreadSnapshot(update.key) + is AppStoreUpdateRecord.ThreadUpserted -> + applyThreadUpsert(update.thread, update.sessionSummary, update.agentDirectoryVersion) + is AppStoreUpdateRecord.ThreadStateUpdated -> + applyThreadStateUpdated(update.state, update.sessionSummary, update.agentDirectoryVersion) + is AppStoreUpdateRecord.ThreadItemUpserted -> { + if (!applyThreadItemUpsert(update.key, update.item)) { + recoverThreadDeltaApplication(update.key) + } + } + is AppStoreUpdateRecord.ThreadCommandExecutionUpdated -> { + if (!applyThreadCommandExecutionUpdated( + update.key, + update.itemId, + update.status, + update.exitCode, + update.durationMs, + update.processId, + ) + ) { + recoverThreadDeltaApplication(update.key) + } + } + is AppStoreUpdateRecord.ThreadStreamingDelta -> { + if (!applyThreadStreamingDelta(update.key, update.itemId, update.kind, update.text)) { + recoverThreadDeltaApplication(update.key) + } + } + is AppStoreUpdateRecord.ThreadRemoved -> + removeThreadSnapshot(update.key, update.agentDirectoryVersion) is AppStoreUpdateRecord.ActiveThreadChanged -> { updateActiveThread(update.key) if (update.key != null && snapshot.value?.threads?.any { it.key == update.key } != true) { @@ -443,6 +489,17 @@ class AppModel private constructor(context: android.content.Context) { } } + private suspend fun recoverThreadDeltaApplication(key: ThreadKey) { + val current = _snapshot.value + val threadMissing = current?.threads?.any { it.key == key } != true + val summaryMissing = current?.sessionSummaries?.any { it.key == key } != true + if (threadMissing && summaryMissing) { + refreshSnapshot() + } else { + refreshThreadSnapshot(key) + } + } + private suspend fun refreshThreadSnapshot(key: ThreadKey) { if (_snapshot.value == null) { refreshSnapshot() @@ -476,10 +533,236 @@ class AppModel private constructor(context: android.content.Context) { _lastError.value = null } - private fun removeThreadSnapshot(key: ThreadKey) { + private fun applyThreadUpsert( + thread: AppThreadSnapshot, + sessionSummary: AppSessionSummary, + agentDirectoryVersion: ULong, + ) { + val current = _snapshot.value ?: return + val existingThreadIndex = current.threads.indexOfFirst { it.key == thread.key } + val updatedThreads = current.threads.toMutableList().apply { + if (existingThreadIndex >= 0) { + this[existingThreadIndex] = thread + } else { + add(thread) + } + } + + val adjustedSummary = applySavedServerName(sessionSummary) + val existingSummaryIndex = current.sessionSummaries.indexOfFirst { it.key == adjustedSummary.key } + val updatedSummaries = current.sessionSummaries.toMutableList().apply { + if (existingSummaryIndex >= 0) { + this[existingSummaryIndex] = adjustedSummary + } else { + add(adjustedSummary) + } + sortWith(compareByDescending { it.updatedAt ?: Long.MIN_VALUE } + .thenBy { it.key.serverId } + .thenBy { it.key.threadId }) + } + + _snapshot.value = current.copy( + threads = updatedThreads, + sessionSummaries = updatedSummaries, + agentDirectoryVersion = agentDirectoryVersion, + ) + _lastError.value = null + } + + private fun applyThreadStateUpdated( + state: uniffi.codex_mobile_client.AppThreadStateRecord, + sessionSummary: AppSessionSummary, + agentDirectoryVersion: ULong, + ) { + val current = _snapshot.value ?: return + val existingThreadIndex = current.threads.indexOfFirst { it.key == state.key } + if (existingThreadIndex < 0) return + + val existingThread = current.threads[existingThreadIndex] + val updatedThread = existingThread.copy( + info = state.info, + model = state.model, + reasoningEffort = state.reasoningEffort, + activeTurnId = state.activeTurnId, + contextTokensUsed = state.contextTokensUsed, + modelContextWindow = state.modelContextWindow, + rateLimitsJson = state.rateLimitsJson, + realtimeSessionId = state.realtimeSessionId, + ) + val updatedThreads = current.threads.toMutableList().apply { + this[existingThreadIndex] = updatedThread + } + + val adjustedSummary = applySavedServerName(sessionSummary) + val existingSummaryIndex = current.sessionSummaries.indexOfFirst { it.key == adjustedSummary.key } + val updatedSummaries = current.sessionSummaries.toMutableList().apply { + if (existingSummaryIndex >= 0) { + this[existingSummaryIndex] = adjustedSummary + } else { + add(adjustedSummary) + } + sortWith(compareByDescending { it.updatedAt ?: Long.MIN_VALUE } + .thenBy { it.key.serverId } + .thenBy { it.key.threadId }) + } + + _snapshot.value = current.copy( + threads = updatedThreads, + sessionSummaries = updatedSummaries, + agentDirectoryVersion = agentDirectoryVersion, + ) + _lastError.value = null + } + + private fun applyThreadItemUpsert( + key: ThreadKey, + item: HydratedConversationItem, + ): Boolean { + val current = _snapshot.value ?: return false + val threadIndex = current.threads.indexOfFirst { it.key == key } + if (threadIndex < 0) return false + + val thread = current.threads[threadIndex] + val updatedItems = thread.hydratedConversationItems.toMutableList() + val existingItemIndex = updatedItems.indexOfFirst { it.id == item.id } + if (existingItemIndex >= 0) { + updatedItems[existingItemIndex] = item + } else { + val insertionIndex = insertionIndexForItem(updatedItems, item) + updatedItems.add(insertionIndex, item) + } + applyThreadSnapshot(thread.copy(hydratedConversationItems = updatedItems)) + return true + } + + private fun applyThreadCommandExecutionUpdated( + key: ThreadKey, + itemId: String, + status: uniffi.codex_mobile_client.AppOperationStatus, + exitCode: Int?, + durationMs: Long?, + processId: String?, + ): Boolean { + val current = _snapshot.value ?: return false + val threadIndex = current.threads.indexOfFirst { it.key == key } + if (threadIndex < 0) return false + + val thread = current.threads[threadIndex] + val itemIndex = thread.hydratedConversationItems.indexOfFirst { it.id == itemId } + if (itemIndex < 0) return false + + val item = thread.hydratedConversationItems[itemIndex] + val content = item.content as? HydratedConversationItemContent.CommandExecution ?: return false + val updatedItems = thread.hydratedConversationItems.toMutableList().apply { + this[itemIndex] = item.copy( + content = HydratedConversationItemContent.CommandExecution( + content.v1.copy( + status = status, + exitCode = exitCode, + durationMs = durationMs, + processId = processId, + ), + ), + ) + } + applyThreadSnapshot(thread.copy(hydratedConversationItems = updatedItems)) + return true + } + + private fun applyThreadStreamingDelta( + key: ThreadKey, + itemId: String, + kind: AppThreadStreamingDeltaKind, + text: String, + ): Boolean { + val current = _snapshot.value ?: return false + val threadIndex = current.threads.indexOfFirst { it.key == key } + if (threadIndex < 0) return false + + val thread = current.threads[threadIndex] + val itemIndex = thread.hydratedConversationItems.indexOfFirst { it.id == itemId } + if (itemIndex < 0) return false + + val updatedContent = applyStreamingDelta(kind, text, thread.hydratedConversationItems[itemIndex].content) + ?: return false + val updatedItems = thread.hydratedConversationItems.toMutableList().apply { + this[itemIndex] = this[itemIndex].copy(content = updatedContent) + } + applyThreadSnapshot(thread.copy(hydratedConversationItems = updatedItems)) + return true + } + + private fun applyStreamingDelta( + kind: AppThreadStreamingDeltaKind, + text: String, + content: HydratedConversationItemContent, + ): HydratedConversationItemContent? = when (kind) { + AppThreadStreamingDeltaKind.ASSISTANT_TEXT -> when (content) { + is HydratedConversationItemContent.Assistant -> + HydratedConversationItemContent.Assistant(content.v1.copy(text = content.v1.text + text)) + else -> null + } + AppThreadStreamingDeltaKind.REASONING_TEXT -> when (content) { + is HydratedConversationItemContent.Reasoning -> { + val updatedContent = content.v1.content.toMutableList().apply { + if (isEmpty()) { + add(text) + } else { + this[lastIndex] = this[lastIndex] + text + } + } + HydratedConversationItemContent.Reasoning(content.v1.copy(content = updatedContent)) + } + else -> null + } + AppThreadStreamingDeltaKind.PLAN_TEXT -> when (content) { + is HydratedConversationItemContent.ProposedPlan -> + HydratedConversationItemContent.ProposedPlan(content.v1.copy(content = content.v1.content + text)) + else -> null + } + AppThreadStreamingDeltaKind.COMMAND_OUTPUT -> when (content) { + is HydratedConversationItemContent.CommandExecution -> + HydratedConversationItemContent.CommandExecution( + content.v1.copy(output = (content.v1.output ?: "") + text) + ) + else -> null + } + AppThreadStreamingDeltaKind.MCP_PROGRESS -> when (content) { + is HydratedConversationItemContent.McpToolCall -> { + val updatedProgress = content.v1.progressMessages.toMutableList().apply { + if (text.isNotBlank()) { + add(text) + } + } + HydratedConversationItemContent.McpToolCall( + content.v1.copy(progressMessages = updatedProgress) + ) + } + else -> null + } + } + + private fun insertionIndexForItem( + items: List, + item: HydratedConversationItem, + ): Int { + val targetTurn = item.sourceTurnIndex?.toInt() ?: return items.size + val lastSameTurn = items.indexOfLast { it.sourceTurnIndex?.toInt() == targetTurn } + if (lastSameTurn >= 0) return lastSameTurn + 1 + + val nextTurn = items.indexOfFirst { + val sourceTurn = it.sourceTurnIndex?.toInt() + sourceTurn != null && sourceTurn > targetTurn + } + return if (nextTurn >= 0) nextTurn else items.size + } + + private fun removeThreadSnapshot(key: ThreadKey, agentDirectoryVersion: ULong? = null) { val current = _snapshot.value ?: return _snapshot.value = current.copy( threads = current.threads.filterNot { it.key == key }, + sessionSummaries = current.sessionSummaries.filterNot { it.key == key }, + agentDirectoryVersion = agentDirectoryVersion ?: current.agentDirectoryVersion, activeThread = if (current.activeThread == key) null else current.activeThread, ) } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/LazyListAutoscroll.kt b/apps/android/app/src/main/java/com/litter/android/ui/LazyListAutoscroll.kt new file mode 100644 index 00000000..af404721 --- /dev/null +++ b/apps/android/app/src/main/java/com/litter/android/ui/LazyListAutoscroll.kt @@ -0,0 +1,38 @@ +package com.litter.android.ui + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow + +internal fun LazyListState.isNearListBottom(bufferItems: Int = 2): Boolean { + val info = layoutInfo + if (info.totalItemsCount == 0) return true + val lastVisible = info.visibleItemsInfo.lastOrNull() ?: return false + return lastVisible.index >= info.totalItemsCount - bufferItems +} + +@Composable +internal fun rememberStickyFollowTail( + listState: LazyListState, + resetKey: Any?, + bufferItems: Int = 2, + initialValue: Boolean = true, +): Boolean { + var shouldFollowTail by remember(resetKey) { mutableStateOf(initialValue) } + + LaunchedEffect(listState, resetKey, bufferItems) { + snapshotFlow { listState.isScrollInProgress } + .collect { isScrolling -> + if (!isScrolling) { + shouldFollowTail = listState.isNearListBottom(bufferItems) + } + } + } + + return shouldFollowTail +} diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt index 43b25582..3ae9374c 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.sp @@ -107,8 +108,9 @@ private val SLASH_COMMANDS = listOf( fun ComposerBar( threadKey: ThreadKey, activeTurnId: String?, - contextPercent: Int, + contextPercent: Int?, isThinking: Boolean, + queuedFollowUps: List = emptyList(), rateLimits: uniffi.codex_mobile_client.RateLimitSnapshot? = null, onToggleModelSelector: (() -> Unit)? = null, onNavigateToSessions: (() -> Unit)? = null, @@ -357,6 +359,38 @@ fun ComposerBar( ) } } + + if (queuedFollowUps.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .background(LitterTheme.codeBackground, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (queuedFollowUps.size == 1) "Queued Follow-Up" else "Queued Follow-Ups", + color = LitterTheme.textPrimary, + fontSize = LitterTextStyle.caption.scaled, + fontWeight = FontWeight.SemiBold, + ) + queuedFollowUps.forEach { preview -> + Text( + text = preview.text, + color = LitterTheme.textSecondary, + fontSize = LitterTextStyle.caption.scaled, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(LitterTheme.surface, RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } + } + // Input row Row( modifier = Modifier @@ -415,175 +449,231 @@ fun ComposerBar( } // Text field - Box( + Row( modifier = Modifier .weight(1f) .heightIn(min = 36.dp, max = 120.dp) .background(LitterTheme.codeBackground, RoundedCornerShape(18.dp)) .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (text.isEmpty()) { - Text( - text = "Message\u2026", - color = LitterTheme.textMuted, - fontSize = LitterTextStyle.body.scaled, + Box(modifier = Modifier.weight(1f)) { + if (text.isEmpty()) { + Text( + text = "Message\u2026", + color = LitterTheme.textMuted, + fontSize = LitterTextStyle.body.scaled, + ) + } + BasicTextField( + value = text, + onValueChange = { text = it }, + textStyle = TextStyle( + color = LitterTheme.textPrimary, + fontSize = LitterTextStyle.body.scaled, + fontFamily = LitterTheme.monoFont, + ), + cursorBrush = SolidColor(LitterTheme.accent), + modifier = Modifier.fillMaxWidth(), ) + + // Slash command popup + DropdownMenu( + expanded = showSlashMenu, + onDismissRequest = { showSlashMenu = false }, + ) { + for (cmd in filteredCommands) { + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("/${cmd.name}", color = LitterTheme.accent, fontSize = LitterTextStyle.footnote.scaled, fontWeight = FontWeight.Medium) + Spacer(Modifier.width(8.dp)) + Text(cmd.description, color = LitterTheme.textMuted, fontSize = 11.sp) + } + }, + onClick = { + showSlashMenu = false + if (dispatchSlashCommand(cmd.name, args = null)) { + text = "" + attachedImage = null + } + }, + ) + } + } + + // @file search popup + DropdownMenu( + expanded = showFileMenu, + onDismissRequest = { showFileMenu = false }, + ) { + for (path in fileSearchResults) { + DropdownMenuItem( + text = { Text(path, color = LitterTheme.textPrimary, fontSize = 12.sp, fontFamily = LitterTheme.monoFont) }, + onClick = { + showFileMenu = false + val atIdx = text.lastIndexOf('@') + if (atIdx >= 0) { + text = text.substring(0, atIdx) + "@$path " + } + }, + ) + } + } } - BasicTextField( - value = text, - onValueChange = { text = it }, - textStyle = TextStyle( - color = LitterTheme.textPrimary, - fontSize = LitterTextStyle.body.scaled, - fontFamily = LitterTheme.monoFont, - ), - cursorBrush = SolidColor(LitterTheme.accent), - modifier = Modifier.fillMaxWidth(), - ) - // Slash command popup - DropdownMenu( - expanded = showSlashMenu, - onDismissRequest = { showSlashMenu = false }, - ) { - for (cmd in filteredCommands) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("/${cmd.name}", color = LitterTheme.accent, fontSize = LitterTextStyle.footnote.scaled, fontWeight = FontWeight.Medium) - Spacer(Modifier.width(8.dp)) - Text(cmd.description, color = LitterTheme.textMuted, fontSize = 11.sp) - } - }, + val canSend = text.isNotBlank() || attachedImage != null + when { + canSend -> { + Spacer(Modifier.width(8.dp)) + IconButton( onClick = { - showSlashMenu = false - if (dispatchSlashCommand(cmd.name, args = null)) { - text = "" - attachedImage = null + parseSlashCommandInvocation(text)?.let { invocation -> + if (dispatchSlashCommand(invocation.command.name, invocation.args)) { + text = "" + attachedImage = null + return@IconButton + } + } + val launchState = appModel.launchState.snapshot.value + val pendingModel = launchState.selectedModel.trim().ifEmpty { null } + val effort = launchState.reasoningEffort.trim().ifEmpty { null }?.let(::reasoningEffortFromServerValue) + val tier = if (HeaderOverrides.pendingFastMode) ServiceTier.FAST else null + val attachmentToSend = attachedImage + val payload = AppComposerPayload( + text = text.trim(), + additionalInputs = listOfNotNull(attachmentToSend?.toUserInput()), + approvalPolicy = appModel.launchState.approvalPolicyValue(), + sandboxPolicy = appModel.launchState.turnSandboxPolicy(), + model = pendingModel, + reasoningEffort = effort, + serviceTier = tier, + ) + text = "" + attachedImage = null + scope.launch { + try { + appModel.startTurn(threadKey, payload) + } catch (e: Exception) { + text = payload.text + attachedImage = attachmentToSend + } } }, - ) + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(LitterTheme.accent, CircleShape), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = Color.Black, + modifier = Modifier.size(16.dp), + ) + } } - } - // @file search popup - DropdownMenu( - expanded = showFileMenu, - onDismissRequest = { showFileMenu = false }, - ) { - for (path in fileSearchResults) { - DropdownMenuItem( - text = { Text(path, color = LitterTheme.textPrimary, fontSize = 12.sp, fontFamily = LitterTheme.monoFont) }, + isRecording -> { + Spacer(Modifier.width(8.dp)) + IconButton( onClick = { - showFileMenu = false - val atIdx = text.lastIndexOf('@') - if (atIdx >= 0) { - text = text.substring(0, atIdx) + "@$path " + scope.launch { + val auth = runCatching { + appModel.rpc.getAuthStatus( + threadKey.serverId, + GetAuthStatusParams( + includeToken = true, + refreshToken = false, + ), + ) + }.getOrNull() + val transcript = transcriptionManager.stopAndTranscribe( + authMethod = auth?.authMethod, + authToken = auth?.authToken, + ) + transcript?.let { text = if (text.isBlank()) it else "$text $it" } } }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.Stop, + contentDescription = "Stop recording", + tint = LitterTheme.accentStrong, + ) + } + } + + isTranscribing -> { + Spacer(Modifier.width(8.dp)) + LinearProgressIndicator( + modifier = Modifier.width(24.dp), + color = LitterTheme.accent, + trackColor = Color.Transparent, ) } + + else -> { + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { transcriptionManager.startRecording(context) }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.Mic, + contentDescription = "Voice", + tint = LitterTheme.textSecondary, + ) + } + } } } Spacer(Modifier.width(4.dp)) - // Send / stop button - val canSend = (text.isNotBlank() || attachedImage != null) && !isThinking - IconButton( - onClick = { - if (isThinking) { - val turnId = activeTurnId ?: return@IconButton - scope.launch { - try { - appModel.rpc.turnInterrupt( - threadKey.serverId, - TurnInterruptParams(threadId = threadKey.threadId, turnId = turnId), - ) - } catch (_: Exception) {} - } - return@IconButton - } - if (!canSend) return@IconButton - parseSlashCommandInvocation(text)?.let { invocation -> - if (dispatchSlashCommand(invocation.command.name, invocation.args)) { - text = "" - attachedImage = null - return@IconButton - } - } - // Apply pending overrides from HeaderBar - val launchState = appModel.launchState.snapshot.value - val pendingModel = launchState.selectedModel.trim().ifEmpty { null } - val effort = launchState.reasoningEffort.trim().ifEmpty { null }?.let(::reasoningEffortFromServerValue) - val tier = if (HeaderOverrides.pendingFastMode) ServiceTier.FAST else null - val attachmentToSend = attachedImage - val payload = AppComposerPayload( - text = text.trim(), - additionalInputs = listOfNotNull(attachmentToSend?.toUserInput()), - approvalPolicy = appModel.launchState.approvalPolicyValue(), - sandboxPolicy = appModel.launchState.turnSandboxPolicy(), - model = pendingModel, - reasoningEffort = effort, - serviceTier = tier, - ) - text = "" - attachedImage = null - scope.launch { - try { - appModel.startTurn(threadKey, payload) - } catch (e: Exception) { - // Restore text on failure - text = payload.text - attachedImage = attachmentToSend + if (isThinking) { + Text( + text = "Cancel", + color = LitterTheme.textPrimary, + fontSize = LitterTextStyle.caption.scaled, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .background(LitterTheme.surface) + .clickable { + val turnId = activeTurnId ?: return@clickable + scope.launch { + try { + appModel.rpc.turnInterrupt( + threadKey.serverId, + TurnInterruptParams(threadId = threadKey.threadId, turnId = turnId), + ) + } catch (_: Exception) {} + } } - } - }, - enabled = isThinking || canSend, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background( - when { - isThinking -> LitterTheme.danger - canSend -> LitterTheme.accent - else -> Color.Transparent - }, - CircleShape, - ), - ) { - Icon( - imageVector = if (isThinking) Icons.Default.Stop else Icons.AutoMirrored.Filled.Send, - contentDescription = if (isThinking) "Interrupt" else "Send", - tint = when { - isThinking -> Color.White - canSend -> Color.Black - else -> LitterTheme.textMuted - }, - modifier = Modifier.size(18.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), ) } } - val hasIndicators = contextPercent > 0 || rateLimits?.primary != null || rateLimits?.secondary != null + val hasIndicators = contextPercent != null || rateLimits?.primary != null || rateLimits?.secondary != null if (hasIndicators) { Row( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 6.dp), - horizontalArrangement = Arrangement.End, + .padding(start = 12.dp, end = 52.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { rateLimits?.primary?.let { window -> RateLimitBadge(window) - Spacer(Modifier.width(6.dp)) } rateLimits?.secondary?.let { window -> RateLimitBadge(window) - Spacer(Modifier.width(6.dp)) } - if (contextPercent > 0) { - ContextBadge(contextPercent) + contextPercent?.let { + ContextBadge(it) } } } @@ -696,7 +786,7 @@ internal fun parseSlashCommandInvocation(text: String): SlashInvocation? { @Composable private fun RateLimitBadge(window: uniffi.codex_mobile_client.RateLimitWindow) { - val remaining = 100 - window.usedPercent + val remaining = (100 - window.usedPercent.toInt()).coerceIn(0, 100) val label = window.windowDurationMins?.let { mins -> when { mins >= 1440 -> "${mins / 1440}d" @@ -704,37 +794,39 @@ private fun RateLimitBadge(window: uniffi.codex_mobile_client.RateLimitWindow) { else -> "${mins}m" } } ?: "?" - val color = when { + val tint = when { remaining <= 10 -> LitterTheme.danger remaining <= 30 -> LitterTheme.warning else -> LitterTheme.textMuted } Row( + horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background(color.copy(alpha = 0.12f), RoundedCornerShape(4.dp)) - .padding(horizontal = 5.dp, vertical = 2.dp), ) { Text( - text = "$label: $remaining%", - color = color, - fontSize = 9.sp, + text = label, + color = LitterTheme.textSecondary, + fontSize = 10.sp, fontWeight = FontWeight.SemiBold, fontFamily = LitterTheme.monoFont, ) + ContextBadge(percent = remaining, tint = tint) } } // ── Context Badge (matching iOS ContextBadgeView) ──────────────────────────── @Composable -private fun ContextBadge(percent: Int) { - val tint = when { +private fun ContextBadge( + percent: Int, + tint: Color = when { percent <= 15 -> LitterTheme.danger percent <= 35 -> LitterTheme.warning else -> LitterTheme.success - } + }, +) { + val normalizedPercent = percent.coerceIn(0, 100) Box( modifier = Modifier @@ -747,12 +839,12 @@ private fun ContextBadge(percent: Int) { Box( modifier = Modifier .fillMaxHeight() - .fillMaxWidth(fraction = percent / 100f) + .fillMaxWidth(fraction = normalizedPercent / 100f) .background(tint.copy(alpha = 0.25f), RoundedCornerShape(4.dp)), ) // Number overlay Text( - text = "$percent", + text = "$normalizedPercent", color = tint, fontSize = 9.sp, fontWeight = FontWeight.ExtraBold, diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt index b30e62a5..9170141a 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationScreen.kt @@ -66,12 +66,15 @@ import com.litter.android.state.contextPercent import com.litter.android.state.hasActiveTurn import com.litter.android.state.isIpcConnected import com.litter.android.state.isActiveStatus +import com.litter.android.ui.BerkeleyMono import com.litter.android.ui.ChatWallpaperBackground import com.litter.android.ui.ConversationPrefs import com.litter.android.ui.LocalAppModel import com.litter.android.ui.LitterTheme import com.litter.android.ui.WallpaperManager import com.litter.android.ui.WallpaperType +import com.litter.android.ui.isNearListBottom +import com.litter.android.ui.rememberStickyFollowTail import kotlinx.coroutines.launch import uniffi.codex_mobile_client.HydratedConversationItemContent import uniffi.codex_mobile_client.ThreadSetNameParams @@ -112,11 +115,12 @@ fun ConversationScreen( expandedRecentTurnCount = if (collapseTurns) 1 else Int.MAX_VALUE, ) } - val transcriptContentSignature = remember(items, normalizedActiveTurnId, isThinking) { + val transcriptTailSignature = remember(items, normalizedActiveTurnId, isThinking) { var hash = 17 - items.forEach { item -> + items.takeLast(4).forEach { item -> hash = 31 * hash + item.hashCode() } + hash = 31 * hash + items.size hash = 31 * hash + (normalizedActiveTurnId?.hashCode() ?: 0) hash = 31 * hash + if (isThinking) 1 else 0 hash @@ -193,7 +197,7 @@ fun ConversationScreen( // Pinned context: latest TODO progress + file change summary val pinnedContext = remember(items) { var todoProgress: String? = null - var diffSummary: String? = null + var diffSummary: DiffSummary? = null for (i in items.indices.reversed()) { when (val c = items[i].content) { is HydratedConversationItemContent.TodoList -> { @@ -206,52 +210,52 @@ fun ConversationScreen( } is HydratedConversationItemContent.FileChange -> { if (diffSummary == null) { - val adds = c.v1.changes.count { it.kind.contains("create", true) || it.kind.contains("edit", true) } - val dels = c.v1.changes.count { it.kind.contains("delete", true) } - if (adds > 0 || dels > 0) diffSummary = "+$adds -$dels" + var additions = 0 + var deletions = 0 + var sawDiff = false + c.v1.changes.forEach { change -> + sawDiff = true + val stats = summarizeDiff(change.diff) + additions += stats.additions + deletions += stats.deletions + } + if (sawDiff) { + diffSummary = DiffSummary(additions = additions, deletions = deletions) + } } } else -> {} } if (todoProgress != null && diffSummary != null) break } - if (todoProgress != null || diffSummary != null) Pair(todoProgress, diffSummary) else null + if (todoProgress != null || diffSummary != null) { + PinnedContextData(todoProgress = todoProgress, diffSummary = diffSummary) + } else { + null + } } // Auto-scroll state val listState = rememberLazyListState() + val shouldFollowTail = rememberStickyFollowTail( + listState = listState, + resetKey = threadKey, + ) val isAtBottom by remember { derivedStateOf { - val info = listState.layoutInfo - if (info.totalItemsCount == 0) true - else { - val lastVisible = info.visibleItemsInfo.lastOrNull() - lastVisible != null && lastVisible.index >= info.totalItemsCount - 2 - } + listState.isNearListBottom() } } - LaunchedEffect(transcriptTurns.size, isAtBottom) { - if (isAtBottom && transcriptTurns.isNotEmpty()) { - listState.animateScrollToItem(transcriptTurns.size) + LaunchedEffect(threadKey, transcriptTurns.size) { + if (shouldFollowTail && transcriptTurns.isNotEmpty()) { + listState.animateScrollToItem(conversationBottomAnchorIndex(transcriptTurns.size)) } } - LaunchedEffect(transcriptContentSignature, isAtBottom, isThinking) { - if (isThinking && isAtBottom && transcriptTurns.isNotEmpty()) { - listState.scrollToItem(transcriptTurns.size) - } - } - - LaunchedEffect(followScrollToken, isThinking, isAtBottom) { - if (isThinking && isAtBottom && transcriptTurns.isNotEmpty()) { - listState.scrollToItem(transcriptTurns.size) - } - } - - LaunchedEffect(streamingRenderTick, isThinking, isAtBottom) { - if (isThinking && isAtBottom && transcriptTurns.isNotEmpty()) { - listState.scrollToItem(transcriptTurns.size) + LaunchedEffect(threadKey, transcriptTailSignature, followScrollToken, streamingRenderTick) { + if (shouldFollowTail && transcriptTurns.isNotEmpty()) { + listState.scrollToItem(conversationBottomAnchorIndex(transcriptTurns.size)) } } @@ -416,7 +420,7 @@ fun ConversationScreen( SmallFloatingActionButton( onClick = { scope.launch { - listState.animateScrollToItem(transcriptTurns.size) + listState.animateScrollToItem(conversationBottomAnchorIndex(transcriptTurns.size)) } }, modifier = Modifier @@ -459,14 +463,14 @@ fun ConversationScreen( .fillMaxWidth() .background(LitterTheme.codeBackground.copy(alpha = if (hasWallpaper) 0.75f else 1f)) .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - pinnedContext.first?.let { todo -> - Text("Plan $todo", color = LitterTheme.accent, fontSize = 11.sp, fontWeight = FontWeight.Medium) + pinnedContext.todoProgress?.let { todo -> + PlanContextBadge(progress = todo) } - pinnedContext.second?.let { diff -> - Text(diff, color = LitterTheme.toolCallFileChange, fontSize = 11.sp, fontWeight = FontWeight.Medium) + pinnedContext.diffSummary?.let { diff -> + DiffSummaryBadge(summary = diff) } } } @@ -475,8 +479,9 @@ fun ConversationScreen( ComposerBar( threadKey = threadKey, activeTurnId = thread?.activeTurnId, - contextPercent = thread?.contextPercent ?: 0, + contextPercent = thread?.composerContextPercent(), isThinking = isThinking, + queuedFollowUps = thread?.queuedFollowUps ?: emptyList(), rateLimits = server?.rateLimits, onToggleModelSelector = { showModelSelector = !showModelSelector }, onNavigateToSessions = onNavigateToSessions, @@ -619,6 +624,103 @@ fun ConversationScreen( } } +private data class PinnedContextData( + val todoProgress: String?, + val diffSummary: DiffSummary?, +) + +private data class DiffSummary( + val additions: Int, + val deletions: Int, +) { + val hasChanges: Boolean + get() = additions > 0 || deletions > 0 +} + +private fun summarizeDiff(diff: String): DiffSummary { + var additions = 0 + var deletions = 0 + diff.lineSequence().forEach { line -> + when { + line.startsWith("+") && !line.startsWith("+++") -> additions += 1 + line.startsWith("-") && !line.startsWith("---") -> deletions += 1 + } + } + return DiffSummary(additions = additions, deletions = deletions) +} + +private fun uniffi.codex_mobile_client.AppThreadSnapshot.composerContextPercent(): Int? { + if (contextTokensUsed == null && modelContextWindow == null) return null + val contextWindow = modelContextWindow?.toLong() + val baseline = 12_000L + if (contextWindow == null || contextWindow <= baseline) { + return contextPercent.coerceIn(0, 100) + } + val totalTokens = contextTokensUsed?.toLong() ?: baseline + val effectiveWindow = contextWindow - baseline + val usedTokens = (totalTokens - baseline).coerceAtLeast(0) + val remainingTokens = (effectiveWindow - usedTokens).coerceAtLeast(0) + return ((remainingTokens.toDouble() / effectiveWindow.toDouble()) * 100.0) + .toInt() + .coerceIn(0, 100) +} + +private fun conversationBottomAnchorIndex(turnCount: Int): Int = turnCount + 1 + +@Composable +private fun PlanContextBadge(progress: String) { + Text( + text = "Plan $progress", + color = LitterTheme.accent, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .background(LitterTheme.surface.copy(alpha = 0.72f), RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) +} + +@Composable +private fun DiffSummaryBadge(summary: DiffSummary) { + Row( + modifier = Modifier + .background(LitterTheme.surface.copy(alpha = 0.72f), RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\u2194", + color = LitterTheme.accent, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + if (summary.hasChanges) { + Text( + text = "+${summary.additions}", + color = LitterTheme.success, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = BerkeleyMono, + ) + Text( + text = "-${summary.deletions}", + color = LitterTheme.danger, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = BerkeleyMono, + ) + } else { + Text( + text = "Diff", + color = LitterTheme.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + /** * Shimmering "Thinking..." text shown while the assistant is working. */ diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/HeaderBar.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/HeaderBar.kt index 8e357955..ff340f08 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/HeaderBar.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/HeaderBar.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator @@ -92,6 +93,30 @@ fun HeaderBar( ?.displayName ?.ifBlank { pendingModelId } ?: pendingModelId.ifBlank { null } + val currentModelId = pendingModelId.ifBlank { + (thread?.model ?: thread?.info?.model ?: "").trim() + } + val selectedModelDefinition = remember(server?.availableModels, currentModelId) { + server?.availableModels?.firstOrNull { it.id == currentModelId } + ?: server?.availableModels?.firstOrNull { it.isDefault } + ?: server?.availableModels?.firstOrNull() + } + val reasoningLabel = remember(launchState.reasoningEffort, thread?.reasoningEffort, selectedModelDefinition) { + val pendingReasoning = launchState.reasoningEffort.trim() + if (pendingReasoning.isNotEmpty()) { + pendingReasoning + } else { + val threadReasoning = thread?.reasoningEffort?.trim().orEmpty() + if (threadReasoning.isNotEmpty()) { + threadReasoning + } else { + selectedModelDefinition?.defaultReasoningEffort?.let(::effortLabel) ?: "default" + } + } + } + val modelLabel = remember(pendingModelLabel, thread?.resolvedModel) { + (pendingModelLabel ?: thread?.resolvedModel).orEmpty().ifBlank { "litter" } + } Column( modifier = Modifier @@ -147,7 +172,7 @@ fun HeaderBar( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = pendingModelLabel ?: thread?.resolvedModel.orEmpty(), + text = modelLabel, color = LitterTheme.textPrimary, fontSize = 13.sp, maxLines = 1, @@ -157,10 +182,25 @@ fun HeaderBar( Spacer(Modifier.width(4.dp)) Text( text = "\u26A1", - color = LitterTheme.accent, - fontSize = 13.sp, + color = LitterTheme.warning, + fontSize = 11.sp, ) } + Spacer(Modifier.width(6.dp)) + Text( + text = reasoningLabel, + color = LitterTheme.textSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(2.dp)) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Open model selector", + tint = LitterTheme.textSecondary, + modifier = Modifier.size(14.dp), + ) } val cwd = thread?.info?.cwd if (cwd != null) { @@ -291,7 +331,7 @@ fun HeaderBar( * Launch model/effort state lives in [AppLaunchState]. */ object HeaderOverrides { - var pendingFastMode: Boolean = false + var pendingFastMode by mutableStateOf(false) } @Composable @@ -306,7 +346,7 @@ private fun ModelSelectorPanel( ?: thread?.model ?: availableModels.firstOrNull { it.isDefault }?.id ?: availableModels.firstOrNull()?.id - var fastMode by remember { mutableStateOf(HeaderOverrides.pendingFastMode) } + val fastMode = HeaderOverrides.pendingFastMode val selectedModelDefinition by remember(selectedModel, availableModels) { derivedStateOf { availableModels.firstOrNull { it.id == selectedModel } @@ -421,7 +461,6 @@ private fun ModelSelectorPanel( Switch( checked = fastMode, onCheckedChange = { - fastMode = it HeaderOverrides.pendingFastMode = it }, colors = SwitchDefaults.colors( diff --git a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt index b4265dbf..7e629268 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/conversation/TurnGrouping.kt @@ -98,7 +98,9 @@ fun buildTranscriptTurns( ): List { if (items.isEmpty()) return emptyList() - val groupedItems = mergeTrailingStreamingGroups(groupItems(items), isStreaming) + val groupedItems = mergeConsecutiveExplorationGroups( + mergeTrailingStreamingGroups(groupItems(items), isStreaming), + ) val collapseBoundary = maxOf(0, groupedItems.size - expandedRecentTurnCount) val lastIndex = groupedItems.lastIndex @@ -165,12 +167,44 @@ private fun mergeTrailingStreamingGroups( } } +private fun mergeConsecutiveExplorationGroups( + groups: List>, +): List> { + val merged = mutableListOf>() + val explorationBuffer = mutableListOf() + + fun flushExplorationBuffer() { + if (explorationBuffer.isEmpty()) return + merged += explorationBuffer.toList() + explorationBuffer.clear() + } + + groups.forEach { group -> + if (group.isExplorationGroup()) { + explorationBuffer += group + } else { + flushExplorationBuffer() + merged += group + } + } + + flushExplorationBuffer() + return merged +} + private fun containsLiveTurnBoundary(items: List): Boolean { return items.any { item -> item.isFromUserTurnBoundary || item.content is HydratedConversationItemContent.User } } +private fun List.isExplorationGroup(): Boolean { + return isNotEmpty() && all { item -> + val content = item.content as? HydratedConversationItemContent.CommandExecution + content?.v1?.isPureExploration() == true + } +} + private fun turnIdentifier(items: List, ordinal: Int): String { val first = items.firstOrNull() ?: return "turn-$ordinal" val sourceTurnId = items.firstNotNullOfOrNull { it.sourceTurnId } @@ -262,6 +296,12 @@ data class ExplorationGroup( val items: List, ) +private data class ExplorationDisplayEntry( + val id: String, + val label: String, + val isInProgress: Boolean, +) + /** * Detects exploration groups in a list of items within a single turn. * Returns a mixed list of either individual items or exploration groups. @@ -311,7 +351,8 @@ fun buildTimelineEntries( fun ExplorationGroupRow(group: ExplorationGroup) { val textScale = LocalTextScale.current var expanded by remember { mutableStateOf(false) } - val isActive = remember(group.items) { group.items.any { it.isInProgressExplorationItem() } } + val entries = remember(group.items) { group.explorationEntries() } + val isActive = remember(entries) { entries.any { it.isInProgress } } val shimmerProgress by rememberInfiniteTransition(label = "exploration-header-shimmer").animateFloat( initialValue = -1f, targetValue = 2f, @@ -344,7 +385,9 @@ fun ExplorationGroupRow(group: ExplorationGroup) { ) Spacer(Modifier.width(6.dp)) Text( - text = if (isActive) "Exploring ${group.items.size} locations" else "Explored ${group.items.size} locations", + text = remember(entries, isActive) { + group.explorationSummaryText(isActive = isActive) + }, color = if (isActive) LitterTheme.textPrimary else LitterTheme.textSecondary, fontSize = LitterTextStyle.caption.scaled, modifier = Modifier @@ -354,8 +397,7 @@ fun ExplorationGroupRow(group: ExplorationGroup) { } if (expanded) { - for (item in group.items) { - val cmd = (item.content as HydratedConversationItemContent.CommandExecution).v1 + for (entry in entries) { Row( modifier = Modifier .fillMaxWidth() @@ -369,7 +411,7 @@ fun ExplorationGroupRow(group: ExplorationGroup) { .width(bulletSize) .height(bulletSize) .background( - color = if (cmd.status == AppOperationStatus.PENDING || cmd.status == AppOperationStatus.IN_PROGRESS) { + color = if (entry.isInProgress) { LitterTheme.warning } else { LitterTheme.textMuted @@ -378,7 +420,7 @@ fun ExplorationGroupRow(group: ExplorationGroup) { ), ) Text( - text = explorationLabel(cmd), + text = entry.label, color = LitterTheme.textSecondary, fontSize = LitterTextStyle.caption.scaled, maxLines = Int.MAX_VALUE, @@ -410,11 +452,77 @@ private fun HydratedConversationItem.isInProgressExplorationItem(): Boolean { return content.v1.status == AppOperationStatus.PENDING || content.v1.status == AppOperationStatus.IN_PROGRESS } -private fun explorationLabel(data: uniffi.codex_mobile_client.HydratedCommandExecutionData): String { - val action = data.actions.firstOrNull() ?: return data.command +private fun ExplorationGroup.explorationEntries(): List { + return items.flatMap { item -> + val content = item.content as? HydratedConversationItemContent.CommandExecution ?: return@flatMap emptyList() + val data = content.v1 + val isInProgress = data.status == AppOperationStatus.PENDING || data.status == AppOperationStatus.IN_PROGRESS + if (data.actions.isEmpty()) { + listOf( + ExplorationDisplayEntry( + id = "${item.id}-command", + label = data.command, + isInProgress = isInProgress, + ), + ) + } else { + data.actions.mapIndexed { index, action -> + ExplorationDisplayEntry( + id = "${item.id}-$index", + label = explorationActionLabel(action, data.command), + isInProgress = isInProgress, + ) + } + } + } +} + +private fun ExplorationGroup.explorationSummaryText(isActive: Boolean): String { + var readCount = 0 + var searchCount = 0 + var listingCount = 0 + var fallbackCount = 0 + + items.forEach { item -> + val content = item.content as? HydratedConversationItemContent.CommandExecution ?: return@forEach + val data = content.v1 + if (data.actions.isEmpty()) { + fallbackCount += 1 + return@forEach + } + data.actions.forEach { action -> + when (action.kind) { + HydratedCommandActionKind.READ -> readCount += 1 + HydratedCommandActionKind.SEARCH -> searchCount += 1 + HydratedCommandActionKind.LIST_FILES -> listingCount += 1 + HydratedCommandActionKind.UNKNOWN -> fallbackCount += 1 + } + } + } + + val parts = buildList { + if (readCount > 0) add("$readCount ${if (readCount == 1) "file" else "files"}") + if (searchCount > 0) add("$searchCount ${if (searchCount == 1) "search" else "searches"}") + if (listingCount > 0) add("$listingCount ${if (listingCount == 1) "listing" else "listings"}") + if (fallbackCount > 0) add("$fallbackCount ${if (fallbackCount == 1) "step" else "steps"}") + } + + val prefix = if (isActive) "Exploring" else "Explored" + return if (parts.isEmpty()) { + val count = explorationEntries().size + "$prefix $count exploration ${if (count == 1) "step" else "steps"}" + } else { + "$prefix ${parts.joinToString(", ")}" + } +} + +private fun explorationActionLabel( + action: uniffi.codex_mobile_client.HydratedCommandActionData, + fallback: String, +): String { return when (action.kind) { HydratedCommandActionKind.READ -> { - action.path?.let { "Read ${workspaceTitle(it)}" } ?: action.command + action.path?.let { "Read ${workspaceTitle(it)}" } ?: fallback } HydratedCommandActionKind.SEARCH -> { @@ -423,15 +531,15 @@ private fun explorationLabel(data: uniffi.codex_mobile_client.HydratedCommandExe "Searched for ${action.query} in ${workspaceTitle(action.path!!)}" !action.query.isNullOrBlank() -> "Searched for ${action.query}" - else -> action.command + else -> fallback } } HydratedCommandActionKind.LIST_FILES -> { - action.path?.let { "Listed files in ${workspaceTitle(it)}" } ?: action.command + action.path?.let { "Listed files in ${workspaceTitle(it)}" } ?: fallback } - HydratedCommandActionKind.UNKNOWN -> data.command + HydratedCommandActionKind.UNKNOWN -> fallback } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/voice/InlineHandoffView.kt b/apps/android/app/src/main/java/com/litter/android/ui/voice/InlineHandoffView.kt index a6439dfa..28aed489 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/voice/InlineHandoffView.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/voice/InlineHandoffView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.litter.android.ui.LocalAppModel import com.litter.android.ui.LitterTheme +import com.litter.android.ui.rememberStickyFollowTail import uniffi.codex_mobile_client.HydratedConversationItemContent import uniffi.codex_mobile_client.ThreadKey @@ -45,11 +46,23 @@ fun InlineHandoffView( } val listState = rememberLazyListState() + val shouldFollowTail = rememberStickyFollowTail( + listState = listState, + resetKey = threadKey, + bufferItems = 1, + ) + val tailContentSignature = remember(items) { + var hash = 17 + items.takeLast(4).forEach { item -> + hash = 31 * hash + item.hashCode() + } + hash = 31 * hash + items.size + hash + } - // Auto-scroll to bottom - LaunchedEffect(items.size) { - if (items.isNotEmpty()) { - listState.animateScrollToItem(items.size - 1) + LaunchedEffect(threadKey, tailContentSignature) { + if (shouldFollowTail && items.isNotEmpty()) { + listState.scrollToItem(items.lastIndex) } } diff --git a/apps/android/app/src/main/java/com/litter/android/ui/voice/RealtimeVoiceScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/voice/RealtimeVoiceScreen.kt index 6edd58eb..5d7be148 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/voice/RealtimeVoiceScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/voice/RealtimeVoiceScreen.kt @@ -71,6 +71,7 @@ import com.litter.android.state.OpenAIApiKeyStore import com.litter.android.state.VoiceRuntimeController import com.litter.android.ui.LitterTheme import com.litter.android.ui.LocalAppModel +import com.litter.android.ui.rememberStickyFollowTail import kotlinx.coroutines.delay import kotlinx.coroutines.launch import uniffi.codex_mobile_client.Account @@ -100,6 +101,11 @@ fun RealtimeVoiceScreen( voiceSession?.transcriptEntries?.filter { it.text.trim().isNotEmpty() }.orEmpty() } val transcriptListState = rememberLazyListState() + val shouldFollowTail = rememberStickyFollowTail( + listState = transcriptListState, + resetKey = threadKey, + bufferItems = 1, + ) var hasCheckedAuth by remember { mutableStateOf(false) } var hasStartedRealtime by remember { mutableStateOf(false) } @@ -128,7 +134,14 @@ fun RealtimeVoiceScreen( } val needsApiKey = hasCheckedAuth && server?.isLocal == true && !hasStoredApiKey val phaseColor = voicePhaseColor(phase) - val transcriptSignature = transcriptEntries.lastOrNull()?.let { "${it.itemId}:${it.text.length}" } + val transcriptTailSignature = remember(transcriptEntries) { + var hash = 17 + transcriptEntries.takeLast(4).forEach { entry -> + hash = 31 * hash + entry.hashCode() + } + hash = 31 * hash + transcriptEntries.size + hash + } LaunchedEffect(threadKey) { try { @@ -158,9 +171,9 @@ fun RealtimeVoiceScreen( } } - LaunchedEffect(transcriptSignature) { - if (transcriptEntries.isNotEmpty()) { - transcriptListState.animateScrollToItem(transcriptEntries.lastIndex) + LaunchedEffect(threadKey, transcriptTailSignature) { + if (shouldFollowTail && transcriptEntries.isNotEmpty()) { + transcriptListState.scrollToItem(transcriptEntries.lastIndex) } } diff --git a/apps/android/app/src/main/java/com/litter/android/util/LLog.kt b/apps/android/app/src/main/java/com/litter/android/util/LLog.kt index ab93851d..1ece1741 100644 --- a/apps/android/app/src/main/java/com/litter/android/util/LLog.kt +++ b/apps/android/app/src/main/java/com/litter/android/util/LLog.kt @@ -1,70 +1,36 @@ package com.litter.android.util import android.content.Context -import android.os.Build -import android.system.Os -import android.provider.Settings import android.util.Log import com.litter.android.core.bridge.UniffiInit -import com.sigkitten.litter.android.BuildConfig import org.json.JSONObject -import uniffi.codex_mobile_client.LogConfig -import uniffi.codex_mobile_client.LogEvent -import uniffi.codex_mobile_client.LogLevel -import uniffi.codex_mobile_client.LogSource -import uniffi.codex_mobile_client.Logs object LLog { @Volatile private var bootstrapped = false - private val logs by lazy { Logs() } fun bootstrap(context: Context) { if (bootstrapped) return synchronized(this) { if (bootstrapped) return UniffiInit.ensure(context) - - // Propagate collector config to env vars so Rust picks them up directly - val collectorUrl = BuildConfig.LOG_COLLECTOR_URL.takeIf { it.isNotEmpty() } - if (collectorUrl != null) Os.setenv("LOG_COLLECTOR_URL", collectorUrl, false) - - logs.configure( - LogConfig( - enabled = false, // Rust will enable based on env vars - collectorUrl = null, - minLevel = LogLevel.DEBUG, - deviceId = Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ANDROID_ID, - ), - deviceName = "${Build.MANUFACTURER} ${Build.MODEL}", - appVersion = appVersion(context), - build = appBuild(context), - ), - ) - bootstrapped = true } } fun t(tag: String, message: String, fields: Map = emptyMap(), payloadJson: String? = null) { - Log.v(tag, message) - emit(LogLevel.TRACE, tag, message, fields, payloadJson) + Log.v(tag, render(message, fields, payloadJson)) } fun d(tag: String, message: String, fields: Map = emptyMap(), payloadJson: String? = null) { - Log.d(tag, message) - emit(LogLevel.DEBUG, tag, message, fields, payloadJson) + Log.d(tag, render(message, fields, payloadJson)) } fun i(tag: String, message: String, fields: Map = emptyMap(), payloadJson: String? = null) { - Log.i(tag, message) - emit(LogLevel.INFO, tag, message, fields, payloadJson) + Log.i(tag, render(message, fields, payloadJson)) } fun w(tag: String, message: String, fields: Map = emptyMap(), payloadJson: String? = null) { - Log.w(tag, message) - emit(LogLevel.WARN, tag, message, fields, payloadJson) + Log.w(tag, render(message, fields, payloadJson)) } fun e( @@ -74,42 +40,24 @@ object LLog { fields: Map = emptyMap(), payloadJson: String? = null, ) { - if (throwable != null) { - Log.e(tag, message, throwable) - } else { - Log.e(tag, message) - } val mergedFields = fields.toMutableMap() if (throwable != null) { mergedFields["error"] = throwable.message ?: throwable.javaClass.simpleName - mergedFields["stack"] = throwable.stackTraceToString() } - emit(LogLevel.ERROR, tag, message, mergedFields, payloadJson) + + val rendered = render(message, mergedFields, payloadJson) + if (throwable != null) { + Log.e(tag, rendered, throwable) + } else { + Log.e(tag, rendered) + } } - private fun emit( - level: LogLevel, - subsystem: String, - message: String, - fields: Map, - payloadJson: String?, - ) { - logs.log( - LogEvent( - timestampMs = null, - level = level, - source = LogSource.ANDROID, - subsystem = subsystem, - category = subsystem, - message = message, - sessionId = null, - serverId = null, - threadId = null, - requestId = null, - payloadJson = payloadJson, - fieldsJson = fieldsJson(fields), - ), - ) + private fun render(message: String, fields: Map, payloadJson: String?): String { + val parts = mutableListOf(message) + fieldsJson(fields)?.let { parts += "fields=$it" } + payloadJson?.takeIf { it.isNotBlank() }?.let { parts += "payload=$it" } + return parts.joinToString(separator = " ") } private fun fieldsJson(fields: Map): String? { @@ -118,14 +66,4 @@ object LLog { if (filtered.isEmpty()) return null return JSONObject(filtered).toString() } - - private fun appVersion(context: Context): String? { - val pkg = context.packageManager.getPackageInfo(context.packageName, 0) - return pkg.versionName - } - - private fun appBuild(context: Context): String { - val pkg = context.packageManager.getPackageInfo(context.packageName, 0) - return pkg.longVersionCode.toString() - } } diff --git a/apps/android/core/bridge/src/main/jniLibs/arm64-v8a/.gitkeep b/apps/android/core/bridge/src/main/jniLibs/arm64-v8a/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/android/scripts/play-upload.sh b/apps/android/scripts/play-upload.sh index fccd20ca..51c5ce4b 100755 --- a/apps/android/scripts/play-upload.sh +++ b/apps/android/scripts/play-upload.sh @@ -9,6 +9,12 @@ GRADLEW="$ANDROID_DIR/gradlew" VARIANT="${VARIANT:-Release}" UPLOAD="${UPLOAD:-1}" TRACK="${LITTER_PLAY_TRACK:-internal}" +GRADLE_MAX_WORKERS="${GRADLE_MAX_WORKERS:-}" + +declare -a GRADLE_ARGS=(--no-daemon) +if [[ -n "$GRADLE_MAX_WORKERS" ]]; then + GRADLE_ARGS+=("--max-workers=$GRADLE_MAX_WORKERS") +fi # Source credentials from env file if present and vars are not already set ENV_FILE="${HOME}/.config/litter/play-upload.env" @@ -44,7 +50,7 @@ if [[ "$UPLOAD" == "1" ]]; then TASK=":app:publish${VARIANT}Bundle" echo "==> Publishing $VARIANT bundle to Google Play track '$TRACK'" - "$GRADLEW" -p "$ANDROID_DIR" "$TASK" \ + "$GRADLEW" -p "$ANDROID_DIR" "${GRADLE_ARGS[@]}" "$TASK" \ -PLITTER_PLAY_SERVICE_ACCOUNT_JSON="$LITTER_PLAY_SERVICE_ACCOUNT_JSON" \ -PLITTER_PLAY_TRACK="$TRACK" \ -PLITTER_UPLOAD_STORE_FILE="$LITTER_UPLOAD_STORE_FILE" \ @@ -54,7 +60,7 @@ if [[ "$UPLOAD" == "1" ]]; then else TASK=":app:bundle${VARIANT}" echo "==> Building local AAB for $VARIANT (no upload)" - "$GRADLEW" -p "$ANDROID_DIR" "$TASK" + "$GRADLEW" -p "$ANDROID_DIR" "${GRADLE_ARGS[@]}" "$TASK" fi echo "==> Done" diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index 641253ca..90f389a9 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -1441,7 +1441,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LOG_COLLECTOR_URL = "$(LOG_COLLECTOR_URL)"; OTHER_LDFLAGS = "$(inherited) -lc++ -lz -lcodex_mobile_client"; PRODUCT_BUNDLE_IDENTIFIER = com.sigkitten.litter; SDKROOT = iphoneos; @@ -1484,7 +1483,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LOG_COLLECTOR_URL = "$(LOG_COLLECTOR_URL)"; PRODUCT_BUNDLE_IDENTIFIER = com.sigkitten.litter; SDKROOT = iphoneos; SWIFT_OBJC_BRIDGING_HEADER = Sources/Litter/Bridge/codex_bridge_objc.h; diff --git a/apps/ios/Sources/Litter/Info.plist b/apps/ios/Sources/Litter/Info.plist index 47186e32..0823879b 100644 --- a/apps/ios/Sources/Litter/Info.plist +++ b/apps/ios/Sources/Litter/Info.plist @@ -102,8 +102,6 @@ fetch remote-notification - LogCollectorURL - $(LOG_COLLECTOR_URL) UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Sources/Litter/Models/AppModel.swift b/apps/ios/Sources/Litter/Models/AppModel.swift index 5b6c91c7..ddaa68c0 100644 --- a/apps/ios/Sources/Litter/Models/AppModel.swift +++ b/apps/ios/Sources/Litter/Models/AppModel.swift @@ -148,10 +148,46 @@ final class AppModel { private func handleStoreUpdate(_ update: AppStoreUpdateRecord) async { switch update { - case .threadChanged(let key): - await refreshThreadSnapshot(key: key) - case .threadRemoved(let key): - removeThreadSnapshot(for: key) + case .threadUpserted(let thread, let sessionSummary, let agentDirectoryVersion): + applyThreadUpsert( + thread, + sessionSummary: sessionSummary, + agentDirectoryVersion: agentDirectoryVersion + ) + case .threadStateUpdated(let state, let sessionSummary, let agentDirectoryVersion): + applyThreadStateUpdated( + state, + sessionSummary: sessionSummary, + agentDirectoryVersion: agentDirectoryVersion + ) + case .threadItemUpserted(let key, let item): + if !applyThreadItemUpsert(key: key, item: item) { + await refreshThreadSnapshot(key: key) + } + case .threadCommandExecutionUpdated( + let key, + let itemId, + let status, + let exitCode, + let durationMs, + let processId + ): + if !applyThreadCommandExecutionUpdated( + key: key, + itemId: itemId, + status: status, + exitCode: exitCode, + durationMs: durationMs, + processId: processId + ) { + await refreshThreadSnapshot(key: key) + } + case .threadStreamingDelta(let key, let itemId, let kind, let text): + if !applyThreadStreamingDelta(key: key, itemId: itemId, kind: kind, text: text) { + await refreshThreadSnapshot(key: key) + } + case .threadRemoved(let key, let agentDirectoryVersion): + removeThreadSnapshot(for: key, agentDirectoryVersion: agentDirectoryVersion) case .activeThreadChanged(let key): updateActiveThread(key) if let key, snapshot?.threadSnapshot(for: key) == nil { @@ -219,12 +255,150 @@ final class AppModel { lastError = nil } - private func removeThreadSnapshot(for key: ThreadKey) { + private func applyThreadUpsert( + _ thread: AppThreadSnapshot, + sessionSummary: AppSessionSummary, + agentDirectoryVersion: UInt64 + ) { + guard var snapshot else { return } + + if let index = snapshot.threads.firstIndex(where: { $0.key == thread.key }) { + snapshot.threads[index] = thread + } else { + snapshot.threads.append(thread) + } + + if let index = snapshot.sessionSummaries.firstIndex(where: { $0.key == sessionSummary.key }) { + snapshot.sessionSummaries[index] = sessionSummary + } else { + snapshot.sessionSummaries.append(sessionSummary) + } + snapshot.sessionSummaries.sort(by: Self.sessionSummarySort(lhs:rhs:)) + snapshot.agentDirectoryVersion = agentDirectoryVersion + self.snapshot = snapshot + lastError = nil + } + + private func applyThreadStateUpdated( + _ state: AppThreadStateRecord, + sessionSummary: AppSessionSummary, + agentDirectoryVersion: UInt64 + ) { + guard var snapshot else { return } + guard let threadIndex = snapshot.threads.firstIndex(where: { $0.key == state.key }) else { + return + } + + var thread = snapshot.threads[threadIndex] + thread.info = state.info + thread.model = state.model + thread.reasoningEffort = state.reasoningEffort + thread.activeTurnId = state.activeTurnId + thread.contextTokensUsed = state.contextTokensUsed + thread.modelContextWindow = state.modelContextWindow + thread.rateLimitsJson = state.rateLimitsJson + thread.realtimeSessionId = state.realtimeSessionId + snapshot.threads[threadIndex] = thread + + if let index = snapshot.sessionSummaries.firstIndex(where: { $0.key == sessionSummary.key }) { + snapshot.sessionSummaries[index] = sessionSummary + } else { + snapshot.sessionSummaries.append(sessionSummary) + } + snapshot.sessionSummaries.sort(by: Self.sessionSummarySort(lhs:rhs:)) + snapshot.agentDirectoryVersion = agentDirectoryVersion + self.snapshot = snapshot + lastError = nil + } + + private func applyThreadItemUpsert( + key: ThreadKey, + item: HydratedConversationItem + ) -> Bool { + guard var snapshot else { return false } + guard let threadIndex = snapshot.threads.firstIndex(where: { $0.key == key }) else { + return false + } + + var thread = snapshot.threads[threadIndex] + if let itemIndex = thread.hydratedConversationItems.firstIndex(where: { $0.id == item.id }) { + thread.hydratedConversationItems[itemIndex] = item + } else { + let insertionIndex = Self.insertionIndex(for: item, in: thread.hydratedConversationItems) + thread.hydratedConversationItems.insert(item, at: insertionIndex) + } + snapshot.threads[threadIndex] = thread + self.snapshot = snapshot + lastError = nil + return true + } + + private func applyThreadCommandExecutionUpdated( + key: ThreadKey, + itemId: String, + status: AppOperationStatus, + exitCode: Int32?, + durationMs: Int64?, + processId: String? + ) -> Bool { + guard var snapshot else { return false } + guard let threadIndex = snapshot.threads.firstIndex(where: { $0.key == key }) else { + return false + } + guard let itemIndex = snapshot.threads[threadIndex].hydratedConversationItems.firstIndex(where: { $0.id == itemId }) else { + return false + } + + var item = snapshot.threads[threadIndex].hydratedConversationItems[itemIndex] + guard case .commandExecution(var data) = item.content else { + return false + } + data.status = status + data.exitCode = exitCode + data.durationMs = durationMs + data.processId = processId + item.content = .commandExecution(data) + snapshot.threads[threadIndex].hydratedConversationItems[itemIndex] = item + self.snapshot = snapshot + lastError = nil + return true + } + + private func applyThreadStreamingDelta( + key: ThreadKey, + itemId: String, + kind: AppThreadStreamingDeltaKind, + text: String + ) -> Bool { + guard var snapshot else { return false } + guard let threadIndex = snapshot.threads.firstIndex(where: { $0.key == key }) else { + return false + } + guard let itemIndex = snapshot.threads[threadIndex].hydratedConversationItems.firstIndex(where: { $0.id == itemId }) else { + return false + } + + var item = snapshot.threads[threadIndex].hydratedConversationItems[itemIndex] + guard let updatedContent = Self.applyingStreamingDelta(kind: kind, text: text, to: item.content) else { + return false + } + item.content = updatedContent + snapshot.threads[threadIndex].hydratedConversationItems[itemIndex] = item + self.snapshot = snapshot + lastError = nil + return true + } + + private func removeThreadSnapshot(for key: ThreadKey, agentDirectoryVersion: UInt64? = nil) { guard var snapshot else { return } snapshot.threads.removeAll { $0.key == key } + snapshot.sessionSummaries.removeAll { $0.key == key } if snapshot.activeThread == key { snapshot.activeThread = nil } + if let agentDirectoryVersion { + snapshot.agentDirectoryVersion = agentDirectoryVersion + } self.snapshot = snapshot } @@ -234,6 +408,69 @@ final class AppModel { self.snapshot = snapshot } + private static func applyingStreamingDelta( + kind: AppThreadStreamingDeltaKind, + text: String, + to content: HydratedConversationItemContent + ) -> HydratedConversationItemContent? { + switch (kind, content) { + case (.assistantText, .assistant(var data)): + data.text += text + return .assistant(data) + case (.reasoningText, .reasoning(var data)): + if data.content.isEmpty { + data.content.append(text) + } else { + data.content[data.content.count - 1] += text + } + return .reasoning(data) + case (.planText, .proposedPlan(var data)): + data.content += text + return .proposedPlan(data) + case (.commandOutput, .commandExecution(var data)): + data.output = (data.output ?? "") + text + return .commandExecution(data) + case (.mcpProgress, .mcpToolCall(var data)): + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + data.progressMessages.append(text) + } + return .mcpToolCall(data) + default: + return nil + } + } + + private static func sessionSummarySort(lhs: AppSessionSummary, rhs: AppSessionSummary) -> Bool { + let lhsUpdatedAt = lhs.updatedAt ?? Int64.min + let rhsUpdatedAt = rhs.updatedAt ?? Int64.min + if lhsUpdatedAt != rhsUpdatedAt { + return lhsUpdatedAt > rhsUpdatedAt + } + if lhs.key.serverId != rhs.key.serverId { + return lhs.key.serverId < rhs.key.serverId + } + return lhs.key.threadId < rhs.key.threadId + } + + private static func insertionIndex( + for item: HydratedConversationItem, + in items: [HydratedConversationItem] + ) -> Int { + guard let targetTurnIndex = item.sourceTurnIndex.map(Int.init) else { + return items.count + } + if let lastSameTurnIndex = items.lastIndex(where: { $0.sourceTurnIndex.map(Int.init) == targetTurnIndex }) { + return lastSameTurnIndex + 1 + } + if let nextTurnIndex = items.firstIndex(where: { + guard let sourceTurnIndex = $0.sourceTurnIndex.map(Int.init) else { return false } + return sourceTurnIndex > targetTurnIndex + }) { + return nextTurnIndex + } + return items.count + } + func queueComposerPrefill(threadKey: ThreadKey, text: String) { composerPrefillRequest = ComposerPrefillRequest(threadKey: threadKey, text: text) } diff --git a/apps/ios/Sources/Litter/Models/ConversationItem.swift b/apps/ios/Sources/Litter/Models/ConversationItem.swift index 4f460150..409fe02f 100644 --- a/apps/ios/Sources/Litter/Models/ConversationItem.swift +++ b/apps/ios/Sources/Litter/Models/ConversationItem.swift @@ -298,6 +298,11 @@ struct ConversationItem: Identifiable, Equatable { return nil } + var isExplorationCommandItem: Bool { + guard case .commandExecution(let data) = content else { return false } + return data.isPureExploration + } + var widgetState: WidgetState? { if case .widget(let data) = content { return data.widgetState diff --git a/apps/ios/Sources/Litter/Models/LLog.swift b/apps/ios/Sources/Litter/Models/LLog.swift index 98bf8a41..7718b3ca 100644 --- a/apps/ios/Sources/Litter/Models/LLog.swift +++ b/apps/ios/Sources/Litter/Models/LLog.swift @@ -1,50 +1,20 @@ import Foundation import OSLog -import UIKit enum LLog { private static let subsystemRoot = Bundle.main.bundleIdentifier ?? "com.sigkitten.litter" - private static let queue = DispatchQueue(label: "com.sigkitten.litter.logging", qos: .utility) private nonisolated(unsafe) static var bootstrapped = false - private static var logs: Logs { - LogsHolder.shared - } - static func bootstrap() { guard !bootstrapped else { return } bootstrapped = true let codexHome = resolveCodexHome() - FileManager.default.createFile(atPath: codexHome.path, contents: nil) setenv("CODEX_HOME", codexHome.path, 1) - - // Propagate collector config from Info.plist → env vars so Rust picks them up directly - if let v = Bundle.main.infoDictionary?["LogCollectorURL"] as? String, !v.isEmpty { - setenv("LOG_COLLECTOR_URL", v, 0) // don't overwrite if already set - } - - // Seed device identity so Rust config can fill in defaults - let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString - let deviceName = UIDevice.current.name - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" - - logs.configure( - config: LogConfig( - enabled: false, // Rust will enable based on env vars - collectorUrl: nil, - minLevel: .debug, - deviceId: deviceId, - deviceName: deviceName, - appVersion: appVersion, - build: build - ) - ) } static func trace(_ subsystem: String, _ message: String, fields: [String: Any] = [:], payloadJson: String? = nil) { - emit(level: .trace, subsystem: subsystem, message: message, fields: fields, payloadJson: payloadJson) + emit(level: .debug, subsystem: subsystem, message: message, fields: fields, payloadJson: payloadJson) } static func debug(_ subsystem: String, _ message: String, fields: [String: Any] = [:], payloadJson: String? = nil) { @@ -56,7 +26,7 @@ enum LLog { } static func warn(_ subsystem: String, _ message: String, fields: [String: Any] = [:], payloadJson: String? = nil) { - emit(level: .warn, subsystem: subsystem, message: message, fields: fields, payloadJson: payloadJson) + emit(level: .default, subsystem: subsystem, message: message, fields: fields, payloadJson: payloadJson) } static func error(_ subsystem: String, _ message: String, error: Error? = nil, fields: [String: Any] = [:], payloadJson: String? = nil) { @@ -67,37 +37,31 @@ enum LLog { emit(level: .error, subsystem: subsystem, message: message, fields: allFields, payloadJson: payloadJson) } - private static func emit(level: LogLevel, subsystem: String, message: String, fields: [String: Any], payloadJson: String?) { + private static func emit(level: OSLogType, subsystem: String, message: String, fields: [String: Any], payloadJson: String?) { let logger = Logger(subsystem: subsystemRoot, category: subsystem) + let rendered = render(message: message, fields: fields, payloadJson: payloadJson) + switch level { - case .trace, .debug: - logger.debug("\(message, privacy: .public)") + case .debug: + logger.debug("\(rendered, privacy: .public)") case .info: - logger.info("\(message, privacy: .public)") - case .warn: - logger.warning("\(message, privacy: .public)") - case .error: - logger.error("\(message, privacy: .public)") + logger.info("\(rendered, privacy: .public)") + case .error, .fault: + logger.error("\(rendered, privacy: .public)") + default: + logger.log(level: level, "\(rendered, privacy: .public)") } + } - queue.async { - logs.log( - event: LogEvent( - timestampMs: nil, - level: level, - source: .ios, - subsystem: subsystem, - category: subsystem, - message: message, - sessionId: nil, - serverId: nil, - threadId: nil, - requestId: nil, - payloadJson: payloadJson, - fieldsJson: jsonString(from: fields) - ) - ) + private static func render(message: String, fields: [String: Any], payloadJson: String?) -> String { + var parts = [message] + if let fieldsJson = jsonString(from: fields) { + parts.append("fields=\(fieldsJson)") } + if let payloadJson, !payloadJson.isEmpty { + parts.append("payload=\(payloadJson)") + } + return parts.joined(separator: " ") } private static func resolveCodexHome() -> URL { @@ -118,7 +82,3 @@ enum LLog { return String(data: data, encoding: .utf8) } } - -private enum LogsHolder { - static let shared = Logs() -} diff --git a/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift b/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift index 0763a96c..225e16ab 100644 --- a/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift @@ -4,6 +4,7 @@ import UIKit struct ConversationComposerContentView: View { let attachedImage: UIImage? let pendingUserInputRequest: PendingUserInputRequest? + let queuedFollowUps: [AppQueuedFollowUpPreview] let rateLimits: RateLimitSnapshot? let contextPercent: Int64? let isTurnActive: Bool @@ -22,6 +23,7 @@ struct ConversationComposerContentView: View { init( attachedImage: UIImage?, pendingUserInputRequest: PendingUserInputRequest?, + queuedFollowUps: [AppQueuedFollowUpPreview], rateLimits: RateLimitSnapshot?, contextPercent: Int64?, isTurnActive: Bool, @@ -39,6 +41,7 @@ struct ConversationComposerContentView: View { ) { self.attachedImage = attachedImage self.pendingUserInputRequest = pendingUserInputRequest + self.queuedFollowUps = queuedFollowUps self.rateLimits = rateLimits self.contextPercent = contextPercent self.isTurnActive = isTurnActive @@ -88,6 +91,12 @@ struct ConversationComposerContentView: View { .padding(.top, 8) } + if !queuedFollowUps.isEmpty { + QueuedFollowUpsPreviewView(previews: queuedFollowUps) + .padding(.horizontal, 12) + .padding(.top, 8) + } + ConversationComposerEntryRowView( showAttachMenu: $showAttachMenu, inputText: $inputText, diff --git a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift index 267f0792..7d2417bf 100644 --- a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift +++ b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift @@ -18,6 +18,7 @@ struct ConversationTranscriptSnapshot { struct ConversationComposerSnapshot { var threadKey: ThreadKey var pendingUserInputRequest: PendingUserInputRequest? + var queuedFollowUps: [AppQueuedFollowUpPreview] var composerPrefillRequest: AppModel.ComposerPrefillRequest? var activeTurnId: String? var isTurnActive: Bool @@ -33,6 +34,7 @@ struct ConversationComposerSnapshot { static let empty = ConversationComposerSnapshot( threadKey: ThreadKey(serverId: "", threadId: ""), pendingUserInputRequest: nil, + queuedFollowUps: [], composerPrefillRequest: nil, activeTurnId: nil, isTurnActive: false, @@ -111,6 +113,7 @@ final class ConversationScreenModel { let composerSnapshot = ConversationComposerSnapshot( threadKey: thread.key, pendingUserInputRequest: pendingUserInputRequest, + queuedFollowUps: thread.queuedFollowUps, composerPrefillRequest: composerPrefillRequest, activeTurnId: activeTurnId, isTurnActive: activeTurnId != nil, diff --git a/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift b/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift index 8764d625..834b31db 100644 --- a/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift @@ -26,9 +26,9 @@ struct ConversationTurnTimeline: View { } private var rowDescriptors: [ConversationTimelineRowDescriptor] { - let rows = ConversationTimelineRowDescriptor.build(from: items) - guard isLive else { return rows } - return ConversationTimelineRowDescriptor.mergeLiveExplorationRows(rows) + ConversationTimelineRowDescriptor.mergeConsecutiveExplorationRows( + ConversationTimelineRowDescriptor.build(from: items) + ) } private var streamingAssistantItemId: String? { @@ -90,12 +90,8 @@ private enum ConversationTimelineRowDescriptor: Identifiable, Equatable { func flushExplorationBuffer() { guard !explorationBuffer.isEmpty else { return } - if explorationBuffer.count == 1 { - rows.append(.item(explorationBuffer[0])) - } else { - let seed = explorationBuffer.first?.id ?? UUID().uuidString - rows.append(.exploration(id: "exploration-\(seed)", items: explorationBuffer)) - } + let seed = explorationBuffer.first?.id ?? UUID().uuidString + rows.append(.exploration(id: "exploration-\(seed)", items: explorationBuffer)) explorationBuffer.removeAll(keepingCapacity: true) } @@ -170,7 +166,7 @@ private enum ConversationTimelineRowDescriptor: Identifiable, Equatable { return rows } - static func mergeLiveExplorationRows( + static func mergeConsecutiveExplorationRows( _ rows: [ConversationTimelineRowDescriptor] ) -> [ConversationTimelineRowDescriptor] { var mergedRows: [ConversationTimelineRowDescriptor] = [] @@ -528,6 +524,9 @@ private struct ConversationExplorationGroupRow: View { @State private var expanded = false var body: some View { + let entries = explorationEntries + let visibleEntries = expanded ? entries : Array(entries.prefix(3)) + VStack(alignment: .leading, spacing: 8) { Button(action: toggleExpanded) { HStack(spacing: 8) { @@ -537,7 +536,6 @@ private struct ConversationExplorationGroupRow: View { Text(summaryText) .litterFont(.caption) .foregroundColor(LitterTheme.textSystem) - .modifier(ConversationExplorationHeaderShimmer(active: isActive)) .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: expanded ? "chevron.up" : "chevron.down") .litterFont(size: 11, weight: .medium) @@ -546,25 +544,21 @@ private struct ConversationExplorationGroupRow: View { } .buttonStyle(.plain) - let visibleItems = expanded ? items : Array(items.prefix(3)) VStack(alignment: .leading, spacing: 6) { - ForEach(visibleItems) { item in - if case .commandExecution(let data) = item.content { - HStack(alignment: .top, spacing: 8) { - Circle() - .fill(data.isInProgress ? LitterTheme.warning : LitterTheme.textMuted) - .frame(width: explorationBulletSize, height: explorationBulletSize) - .padding(.top, explorationBulletTopPadding) - Text(explorationLabel(for: data)) - .litterFont(.caption) - .foregroundColor(LitterTheme.textSecondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } + ForEach(visibleEntries) { entry in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(entry.isInProgress ? LitterTheme.warning : LitterTheme.textMuted) + .frame(width: explorationBulletSize, height: explorationBulletSize) + .padding(.top, explorationBulletTopPadding) + Text(entry.label) + .litterFont(.caption) + .foregroundColor(LitterTheme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) } } - if !expanded && items.count > visibleItems.count { - Text("+\(items.count - visibleItems.count) more") + if !expanded && entries.count > visibleEntries.count { + Text("+\(entries.count - visibleEntries.count) more") .litterFont(.caption2) .foregroundColor(LitterTheme.textMuted) } @@ -575,12 +569,8 @@ private struct ConversationExplorationGroupRow: View { } private var summaryText: String { - let count = items.count let prefix = isActive ? "Exploring" : "Explored" - if items.contains(where: \.isExplorationCommandItem) { - return count == 1 ? "\(prefix) 1 location" : "\(prefix) \(count) locations" - } - return count == 1 ? "Exploration" : "\(count) exploration steps" + return explorationSummaryText(prefix: prefix) } private var explorationBulletSize: CGFloat { @@ -592,81 +582,109 @@ private struct ConversationExplorationGroupRow: View { } private var isActive: Bool { - items.contains { item in - guard case .commandExecution(let data) = item.content else { return false } - return data.isInProgress - } + explorationEntries.contains(where: \.isInProgress) } private func toggleExpanded() { - withAnimation(.easeInOut(duration: 0.5)) { + withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } } - private func explorationLabel(for data: ConversationCommandExecutionData) -> String { - if let action = data.actions.first { - switch action.kind { - case .read: - return action.path.map { "Read \(workspaceTitle(for: $0))" } ?? action.command - case .search: - if let query = action.query, let path = action.path { - return "Searched for \(query) in \(workspaceTitle(for: path))" - } - if let query = action.query { - return "Searched for \(query)" - } - return action.command - case .listFiles: - return action.path.map { "Listed files in \(workspaceTitle(for: $0))" } ?? action.command - case .unknown: - break + private var explorationEntries: [ExplorationDisplayEntry] { + items.flatMap { item -> [ExplorationDisplayEntry] in + guard case .commandExecution(let data) = item.content else { return [] } + if data.actions.isEmpty { + return [ + ExplorationDisplayEntry( + id: "\(item.id)-command", + label: data.command, + isInProgress: data.isInProgress + ) + ] + } + return data.actions.enumerated().map { index, action in + ExplorationDisplayEntry( + id: "\(item.id)-\(index)", + label: explorationLabel(for: action, fallback: data.command), + isInProgress: data.isInProgress + ) } } - return data.command } -} -private extension ConversationItem { - var isExplorationCommandItem: Bool { - guard case .commandExecution(let data) = content else { return false } - return data.isPureExploration - } -} + private func explorationSummaryText(prefix: String) -> String { + var readCount = 0 + var searchCount = 0 + var listingCount = 0 + var fallbackCount = 0 -private struct ConversationExplorationHeaderShimmer: ViewModifier { - let active: Bool + for item in items { + guard case .commandExecution(let data) = item.content else { continue } + if data.actions.isEmpty { + fallbackCount += 1 + continue + } + for action in data.actions { + switch action.kind { + case .read: + readCount += 1 + case .search: + searchCount += 1 + case .listFiles: + listingCount += 1 + case .unknown: + fallbackCount += 1 + } + } + } - func body(content: Content) -> some View { - if active { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - let t = timeline.date.timeIntervalSinceReferenceDate - let phase = CGFloat(t.truncatingRemainder(dividingBy: 2.0) / 2.0) + var parts: [String] = [] + if readCount > 0 { + parts.append("\(readCount) \(readCount == 1 ? "file" : "files")") + } + if searchCount > 0 { + parts.append("\(searchCount) \(searchCount == 1 ? "search" : "searches")") + } + if listingCount > 0 { + parts.append("\(listingCount) \(listingCount == 1 ? "listing" : "listings")") + } + if fallbackCount > 0 { + parts.append("\(fallbackCount) \(fallbackCount == 1 ? "step" : "steps")") + } + if parts.isEmpty { + let count = explorationEntries.count + return count == 1 ? "\(prefix) 1 exploration step" : "\(prefix) \(count) exploration steps" + } + return "\(prefix) \(parts.joined(separator: ", "))" + } - content - .overlay { - GeometryReader { geo in - LinearGradient( - stops: [ - .init(color: .white.opacity(0), location: max(0, phase - 0.2)), - .init(color: .white.opacity(0.35), location: phase), - .init(color: .white.opacity(0), location: min(1, phase + 0.2)) - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: geo.size.width, height: geo.size.height) - } - .blendMode(.sourceAtop) - } - .compositingGroup() + private func explorationLabel(for action: ConversationCommandAction, fallback: String) -> String { + switch action.kind { + case .read: + return action.path.map { "Read \(workspaceTitle(for: $0))" } ?? fallback + case .search: + if let query = action.query, let path = action.path { + return "Searched for \(query) in \(workspaceTitle(for: path))" } - } else { - content + if let query = action.query { + return "Searched for \(query)" + } + return fallback + case .listFiles: + return action.path.map { "Listed files in \(workspaceTitle(for: $0))" } ?? fallback + case .unknown: + return fallback } } } +private struct ExplorationDisplayEntry: Identifiable { + let id: String + let label: String + let isInProgress: Bool +} + private struct ConversationReasoningRow: View { let data: ConversationReasoningData @@ -1233,7 +1251,16 @@ struct ConversationPinnedContextStrip: View { private var pinnedDiff: ConversationItem? { items.last(where: { - if case .turnDiff = $0.content { return true } + if case .fileChange(let data) = $0.content { + return data.changes.contains { + !$0.diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + return false + }) ?? items.last(where: { + if case .turnDiff(let data) = $0.content { + return !data.diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } return false }) } @@ -1322,17 +1349,40 @@ struct ConversationPinnedContextStrip: View { @ViewBuilder private func diffIndicatorButton(for item: ConversationItem) -> some View { - if case .turnDiff(let data) = item.content { + if let presented = presentedPinnedDiff(for: item) { Button { - selectedDiff = PresentedDiff( - id: item.id, - title: "Turn Diff", - diff: data.diff - ) + selectedDiff = presented } label: { - DiffIndicatorLabel(diff: data.diff) + DiffIndicatorLabel(diff: presented.diff) } .buttonStyle(.plain) + .fixedSize(horizontal: true, vertical: false) + } + } + + private func presentedPinnedDiff(for item: ConversationItem) -> PresentedDiff? { + switch item.content { + case .fileChange(let data): + let diffs = data.changes + .map(\.diff) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !diffs.isEmpty else { return nil } + return PresentedDiff( + id: item.id, + title: "File Diff", + diff: diffs.joined(separator: "\n\n") + ) + case .turnDiff(let data): + let diff = data.diff.trimmingCharacters(in: .whitespacesAndNewlines) + guard !diff.isEmpty else { return nil } + return PresentedDiff( + id: item.id, + title: "Turn Diff", + diff: diff + ) + default: + return nil } } } @@ -1423,8 +1473,8 @@ private struct DiffIndicatorLabel: View { } .padding(.horizontal, 10) .padding(.vertical, 8) - .background(LitterTheme.surface.opacity(0.72)) - .clipShape(Capsule()) + .background(LitterTheme.surface.opacity(0.72), in: Capsule()) + .fixedSize(horizontal: true, vertical: false) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) } diff --git a/apps/ios/Sources/Litter/Views/ConversationView.swift b/apps/ios/Sources/Litter/Views/ConversationView.swift index 8d45eb19..6b210cbb 100644 --- a/apps/ios/Sources/Litter/Views/ConversationView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationView.swift @@ -410,7 +410,7 @@ private struct ConversationMessageList: View { } private var displayedTurns: [TranscriptTurn] { - if isStreaming { + if transcriptTurns.isEmpty { return TranscriptTurn.build( from: items, threadStatus: threadStatus, @@ -420,8 +420,12 @@ private struct ConversationMessageList: View { return transcriptTurns } + private var renderedTurns: [TranscriptTurn] { + mergeConsecutiveExplorationTurns(in: displayedTurns) + } + var body: some View { - let turns = displayedTurns + let turns = renderedTurns let lastTurnID = turns.last?.id ScrollViewReader { proxy in GeometryReader { viewport in @@ -467,7 +471,6 @@ private struct ConversationMessageList: View { } .frame(maxWidth: .infinity, minHeight: viewport.size.height, alignment: .top) } - .defaultScrollAnchor(.bottom) .onAppear { syncTranscriptTurns() syncRichRenderedTurns(reset: true) @@ -523,17 +526,25 @@ private struct ConversationMessageList: View { syncRichRenderedTurns(reset: true) scheduleScrollToBottom(proxy, delay: 0.06, force: true, animation: nil) } - .onChange(of: items) { _, _ in + .onChange(of: items) { oldItems, newItems in syncTranscriptTurns() - syncRichRenderedTurns() - } - .onChange(of: items.count) { - scheduleScrollToBottom(proxy) + if !isStreaming || oldItems.count != newItems.count { + syncRichRenderedTurns() + } } .onChange(of: collapseTurns) { syncTranscriptTurns(resetExpansion: true) syncRichRenderedTurns(reset: true) } + .onChange(of: followScrollToken) { + guard isStreaming else { return } + scheduleScrollToBottom( + proxy, + delay: 0.01, + replacePending: true, + animation: nil + ) + } .onChange(of: threadStatus) { syncTranscriptTurns() syncRichRenderedTurns() @@ -545,32 +556,22 @@ private struct ConversationMessageList: View { animation: .linear(duration: 0.12) ) } else { + let shouldFinalizeAtBottom = autoFollowStreaming || isNearBottom userIsDraggingScroll = false - scheduleScrollToBottom(proxy, delay: 0.1, force: true) + scheduleScrollToBottom( + proxy, + delay: 0.08, + force: shouldFinalizeAtBottom, + animation: nil + ) } } - .onChange(of: followScrollToken) { - guard isStreaming else { return } - scheduleScrollToBottom( - proxy, - delay: 0.06, - animation: .linear(duration: 0.12) - ) - } .onChange(of: streamingRenderTick) { guard isStreaming else { return } scheduleScrollToBottom( proxy, - delay: 0, - replacePending: true, - animation: .linear(duration: 0.09) - ) - } - .onChange(of: transcriptLayoutTick) { - scheduleScrollToBottom( - proxy, - delay: 0.01, - replacePending: true, + delay: 0.02, + replacePending: false, animation: nil ) } @@ -793,7 +794,7 @@ private struct ConversationMessageList: View { if let removeExpandedTurnID { expandedTurnIDs.remove(removeExpandedTurnID) } - if previousLayoutSignature != nextLayoutSignature { + if !isStreaming && previousLayoutSignature != nextLayoutSignature { transcriptLayoutTick &+= 1 } } @@ -806,12 +807,12 @@ private struct ConversationMessageList: View { } private func syncRichRenderedTurns(reset: Bool = false) { - let nextTurnIDs = Set(displayedTurns.map(\.id)) + let nextTurnIDs = Set(renderedTurns.map(\.id)) if reset { - richRenderedTurnIDs = Set(displayedTurns.filter(\.isLive).map(\.id)) + richRenderedTurnIDs = Set(renderedTurns.filter(\.isLive).map(\.id)) } else { richRenderedTurnIDs.formIntersection(nextTurnIDs) - for turn in displayedTurns where turn.isLive { + for turn in renderedTurns where turn.isLive { richRenderedTurnIDs.insert(turn.id) } } @@ -822,7 +823,7 @@ private struct ConversationMessageList: View { pendingRichRenderPromotion?.cancel() pendingRichRenderPromotion = nil - guard let targetTurn = displayedTurns.last else { return } + guard let targetTurn = renderedTurns.last else { return } guard !targetTurn.isLive, !richRenderedTurnIDs.contains(targetTurn.id) else { return } let work = DispatchWorkItem { @@ -869,6 +870,51 @@ private struct ConversationMessageList: View { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) } } + + private func mergeConsecutiveExplorationTurns( + in turns: [TranscriptTurn] + ) -> [TranscriptTurn] { + var merged: [TranscriptTurn] = [] + var explorationBuffer: [TranscriptTurn] = [] + + func flushExplorationBuffer() { + guard !explorationBuffer.isEmpty else { return } + if explorationBuffer.count == 1, let single = explorationBuffer.first { + merged.append(single) + } else if let first = explorationBuffer.first { + let items = explorationBuffer.flatMap(\.items) + var hasher = Hasher() + hasher.combine(items.count) + for item in items { + hasher.combine(item.id) + hasher.combine(item.renderDigest) + } + merged.append( + TranscriptTurn( + id: "exploration-turn-\(first.id)", + items: items, + preview: first.preview, + isLive: explorationBuffer.contains(where: \.isLive), + isCollapsedByDefault: false, + renderDigest: hasher.finalize() + ) + ) + } + explorationBuffer.removeAll(keepingCapacity: true) + } + + for turn in turns { + if turn.items.allSatisfy(\.isExplorationCommandItem) { + explorationBuffer.append(turn) + } else { + flushExplorationBuffer() + merged.append(turn) + } + } + + flushExplorationBuffer() + return merged + } } private struct ConversationTurnRow: View { @@ -1305,6 +1351,7 @@ private struct ConversationInputBar: View { ConversationComposerContentView( attachedImage: attachedImage, pendingUserInputRequest: pendingUserInputRequest, + queuedFollowUps: snapshot.queuedFollowUps, rateLimits: snapshot.rateLimits, contextPercent: contextPercent(), isTurnActive: isTurnActive, @@ -2402,6 +2449,38 @@ struct PendingUserInputPromptView: View { } } +struct QueuedFollowUpsPreviewView: View { + let previews: [AppQueuedFollowUpPreview] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(LitterTheme.accent) + Text(previews.count == 1 ? "Queued Follow-Up" : "Queued Follow-Ups") + .litterFont(.caption, weight: .semibold) + .foregroundColor(LitterTheme.textPrimary) + Spacer() + } + + ForEach(previews, id: \.id) { preview in + Text(preview.text) + .litterFont(.caption) + .foregroundColor(LitterTheme.textSecondary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(LitterTheme.surface.opacity(0.82)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(12) + .background(LitterTheme.codeBackground.opacity(0.92)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } +} + struct TypingIndicator: View { @State private var shimmerOffset: CGFloat = -1 diff --git a/apps/ios/Sources/Litter/Views/PreviewSupport.swift b/apps/ios/Sources/Litter/Views/PreviewSupport.swift index 7030c1d1..435c064b 100644 --- a/apps/ios/Sources/Litter/Views/PreviewSupport.swift +++ b/apps/ios/Sources/Litter/Views/PreviewSupport.swift @@ -323,6 +323,7 @@ enum LitterPreviewData { model: model, reasoningEffort: reasoningEffort, hydratedConversationItems: makeHydratedConversationItems(from: messages), + queuedFollowUps: [], activeTurnId: status == .active ? "turn-preview" : nil, contextTokensUsed: 156_000, modelContextWindow: 200_000, diff --git a/apps/ios/project.yml b/apps/ios/project.yml index f30df8db..719bfb89 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,7 +92,6 @@ targets: SWIFT_OBJC_BRIDGING_HEADER: Sources/Litter/Bridge/codex_bridge_objc.h ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon INFOPLIST_KEY_UIBackgroundModes: "audio fetch remote-notification" - LOG_COLLECTOR_URL: $(LOG_COLLECTOR_URL) configs: Debug: OTHER_LDFLAGS: "$(inherited) -lc++ -lz -lcodex_mobile_client" diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index 7953664a..7ba0d302 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -2045,7 +2045,6 @@ dependencies = [ name = "codex-mobile-client" version = "0.1.0" dependencies = [ - "arc-swap", "async-trait", "base64 0.22.1", "chrono", @@ -2060,10 +2059,8 @@ dependencies = [ "codex-ios-audio", "codex-ipc", "codex-protocol", - "flate2", "futures", "lru 0.12.5", - "mobile-log-shared", "openssl-sys", "regex", "reqwest", @@ -3633,18 +3630,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.3.0" @@ -4240,15 +4225,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "hashlink" version = "0.10.0" @@ -5710,31 +5686,6 @@ dependencies = [ "sha3", ] -[[package]] -name = "mobile-log-collector" -version = "0.1.0" -dependencies = [ - "axum", - "chrono", - "clap", - "flate2", - "mobile-log-shared", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "tokio", - "tokio-stream", - "uuid", -] - -[[package]] -name = "mobile-log-shared" -version = "0.1.0" -dependencies = [ - "serde", -] - [[package]] name = "moka" version = "0.12.15" @@ -7874,20 +7825,6 @@ name = "runfiles" version = "0.1.0" source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81" -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink 0.9.1", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "russh" version = "0.58.1" @@ -9125,7 +9062,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink 0.10.0", + "hashlink", "indexmap 2.13.0", "log", "memchr", diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index e5c52750..5c7f2da2 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -4,8 +4,6 @@ members = [ "codex-ios-audio", "codex-ipc", "codex-mobile-client", - "mobile-log-collector", - "mobile-log-shared", "uniffi-bindgen", "codegen", "codex-tui", @@ -40,14 +38,8 @@ russh-keys = "0.49.2" futures = "0.3" url = "2.5" uuid = { version = "1", features = ["v4", "serde"] } -flate2 = "1" reqwest = { version = "0.12", default-features = false, features = ["http2", "json", "rustls-tls"] } tracing-subscriber = { version = "0.3", features = ["registry"] } -axum = "0.8" -clap = { version = "4", features = ["derive"] } -rusqlite = "0.32.1" -tokio-stream = { version = "0.1", features = ["sync"] } -arc-swap = "1.7" [profile.release] strip = true @@ -56,8 +48,8 @@ lto = true [profile.android-dev] inherits = "release" lto = false -strip = false -debug = 1 +strip = true +debug = 0 codegen-units = 16 [patch.crates-io] diff --git a/shared/rust-bridge/codex-ipc/src/client/connection.rs b/shared/rust-bridge/codex-ipc/src/client/connection.rs index 09912c06..48e18713 100644 --- a/shared/rust-bridge/codex-ipc/src/client/connection.rs +++ b/shared/rust-bridge/codex-ipc/src/client/connection.rs @@ -13,6 +13,7 @@ use crate::protocol::envelope::{ClientDiscoveryResponse, DiscoveryAnswer, Envelo use crate::protocol::method::Method; use crate::protocol::params::{TypedBroadcast, TypedRequest}; use crate::transport::frame; +use crate::wire_trace::{RawFrameDirection, emit_raw_frame_trace}; /// Active IPC connection with spawned read/write loop tasks. pub struct IpcConnection { @@ -115,6 +116,7 @@ impl IpcConnection { break; } }; + emit_raw_frame_trace(RawFrameDirection::In, &raw); let envelope: Envelope = match serde_json::from_str(&raw) { Ok(e) => e, @@ -213,6 +215,7 @@ impl IpcConnection { continue; } }; + emit_raw_frame_trace(RawFrameDirection::Out, &json_str); if let Err(e) = frame::write_frame(&mut writer, &json_str).await { error!("ipc: write error: {e}"); break; diff --git a/shared/rust-bridge/codex-ipc/src/conversation_state.rs b/shared/rust-bridge/codex-ipc/src/conversation_state.rs index 114f7920..f8548747 100644 --- a/shared/rust-bridge/codex-ipc/src/conversation_state.rs +++ b/shared/rust-bridge/codex-ipc/src/conversation_state.rs @@ -25,6 +25,12 @@ pub struct ProjectedConversationState { pub pending_user_inputs: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectedConversationRequestState { + pub pending_approvals: Vec, + pub pending_user_inputs: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProjectedApprovalKind { Command, @@ -103,8 +109,6 @@ pub enum ConversationStreamPatchError { pub enum ConversationStreamApplyError { #[error("no cached state")] NoCachedState, - #[error("version gap (expected {expected}, got {actual})")] - VersionGap { expected: u32, actual: u32 }, #[error("patch apply failed: {0}")] PatchFailed(#[from] ConversationStreamPatchError), } @@ -150,6 +154,17 @@ struct DesktopConversationState { requests: Vec, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopConversationRequestState { + #[serde(default)] + agent_nickname: Option, + #[serde(default)] + agent_role: Option, + #[serde(default)] + requests: Vec, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopTurn { @@ -284,6 +299,32 @@ pub fn project_conversation_state( }) } +pub fn project_conversation_request_state( + conversation_state: &Value, +) -> Result { + let conversation: DesktopConversationRequestState = + serde_json::from_value(conversation_state.clone()) + .map_err(ConversationProjectionError::ConversationState)?; + + Ok(ProjectedConversationRequestState { + pending_approvals: project_pending_approvals(&conversation.requests), + pending_user_inputs: project_pending_user_inputs( + &conversation.requests, + conversation.agent_nickname, + conversation.agent_role, + ), + }) +} + +pub fn project_conversation_turn( + raw_turn: &Value, + turn_index: usize, +) -> Result { + let turn: DesktopTurn = serde_json::from_value(raw_turn.clone()) + .map_err(ConversationProjectionError::ConversationState)?; + project_turn(&turn, turn_index) +} + pub fn seed_conversation_state_from_thread(thread: &upstream::Thread) -> Value { serde_json::json!({ "title": thread.name.clone(), @@ -309,29 +350,23 @@ pub fn seed_conversation_state_from_thread(thread: &upstream::Thread) -> Value { } pub fn apply_stream_change_to_conversation_state( - cached_state: &mut Option<(u32, Value)>, + cached_state: &mut Option, params: &ThreadStreamStateChangedParams, ) -> Result<(), ConversationStreamApplyError> { + // Desktop currently sends the IPC method/schema version here, not a + // monotonic per-thread stream revision, so ordering recovery has to rely + // on whether a snapshot exists and whether the patch still applies cleanly. match ¶ms.change { StreamChange::Snapshot { conversation_state } => { - *cached_state = Some((params.version, conversation_state.clone())); + *cached_state = Some(conversation_state.clone()); Ok(()) } StreamChange::Patches { patches } => { - let Some((cached_version, cached_json)) = cached_state.as_mut() else { + let Some(cached_json) = cached_state.as_mut() else { return Err(ConversationStreamApplyError::NoCachedState); }; - let expected = *cached_version + 1; - if params.version != expected { - return Err(ConversationStreamApplyError::VersionGap { - expected, - actual: params.version, - }); - } - apply_immer_patches(cached_json, patches)?; - *cached_version = params.version; Ok(()) } } @@ -1304,8 +1339,8 @@ mod tests { }], }; - let mut cached_state = Some((1, seed_conversation_state_from_thread(&thread))); - let seeded_state = &cached_state.as_ref().unwrap().1; + let mut cached_state = Some(seed_conversation_state_from_thread(&thread)); + let seeded_state = cached_state.as_ref().unwrap(); assert_eq!( seeded_state["turns"][0]["params"]["input"][0]["text"], "hello" @@ -1332,12 +1367,100 @@ mod tests { apply_stream_change_to_conversation_state(&mut cached_state, &text_patch).unwrap(); let projected = - project_conversation_state("conversation-1", &cached_state.as_ref().unwrap().1) - .unwrap(); + project_conversation_state("conversation-1", cached_state.as_ref().unwrap()).unwrap(); assert_eq!(projected.active_turn_id.as_deref(), Some("turn-1")); match &projected.thread.turns[0].items[1] { upstream::ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "hello"), other => panic!("expected agent message, got {other:?}"), } } + + #[test] + fn applies_same_protocol_version_patch_bursts() { + let thread = upstream::Thread { + id: "thread-1".to_string(), + preview: "hello".to_string(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: upstream::ThreadStatus::Active { + active_flags: Vec::new(), + }, + path: Some(PathBuf::from("/tmp/thread.jsonl")), + cwd: PathBuf::from("/tmp"), + cli_version: "1.0.0".to_string(), + source: upstream::SessionSource::default(), + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some("Thread".to_string()), + turns: vec![upstream::Turn { + id: "turn-1".to_string(), + status: upstream::TurnStatus::InProgress, + error: None, + items: vec![ + upstream::ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![upstream::UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + }, + upstream::ThreadItem::AgentMessage { + id: "assistant-1".to_string(), + text: "hel".to_string(), + phase: None, + memory_citation: None, + }, + ], + }], + }; + + let mut cached_state = Some(seed_conversation_state_from_thread(&thread)); + let first_text_patch = ThreadStreamStateChangedParams { + conversation_id: "conversation-1".to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![crate::protocol::params::ImmerPatch { + op: crate::protocol::params::ImmerOp::Replace, + path: vec![ + crate::protocol::params::ImmerPathSegment::Key("turns".to_string()), + crate::protocol::params::ImmerPathSegment::Index(0), + crate::protocol::params::ImmerPathSegment::Key("items".to_string()), + crate::protocol::params::ImmerPathSegment::Index(0), + crate::protocol::params::ImmerPathSegment::Key("text".to_string()), + ], + value: Some(json!("hell")), + }], + }, + }; + let second_text_patch = ThreadStreamStateChangedParams { + conversation_id: "conversation-1".to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![crate::protocol::params::ImmerPatch { + op: crate::protocol::params::ImmerOp::Replace, + path: vec![ + crate::protocol::params::ImmerPathSegment::Key("turns".to_string()), + crate::protocol::params::ImmerPathSegment::Index(0), + crate::protocol::params::ImmerPathSegment::Key("items".to_string()), + crate::protocol::params::ImmerPathSegment::Index(0), + crate::protocol::params::ImmerPathSegment::Key("text".to_string()), + ], + value: Some(json!("hello")), + }], + }, + }; + + apply_stream_change_to_conversation_state(&mut cached_state, &first_text_patch).unwrap(); + apply_stream_change_to_conversation_state(&mut cached_state, &second_text_patch).unwrap(); + + let state = cached_state.expect("state after same-version patches"); + let projected = project_conversation_state("conversation-1", &state).unwrap(); + match &projected.thread.turns[0].items[1] { + upstream::ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "hello"), + other => panic!("expected agent message, got {other:?}"), + } + } } diff --git a/shared/rust-bridge/codex-ipc/src/lib.rs b/shared/rust-bridge/codex-ipc/src/lib.rs index 5ca0754b..6a53cc9f 100644 --- a/shared/rust-bridge/codex-ipc/src/lib.rs +++ b/shared/rust-bridge/codex-ipc/src/lib.rs @@ -23,6 +23,7 @@ pub mod error; pub mod handler; pub mod protocol; pub mod transport; +pub mod wire_trace; pub use client::handle::{IpcClient, IpcClientConfig}; pub use client::reconnect::{ReconnectPolicy, ReconnectingIpcClient}; @@ -32,3 +33,4 @@ pub use handler::RequestHandler; pub use protocol::envelope::*; pub use protocol::method::Method; pub use protocol::params::*; +pub use wire_trace::*; diff --git a/shared/rust-bridge/codex-ipc/src/wire_trace.rs b/shared/rust-bridge/codex-ipc/src/wire_trace.rs new file mode 100644 index 00000000..f8602d60 --- /dev/null +++ b/shared/rust-bridge/codex-ipc/src/wire_trace.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RawFrameDirection { + In, + Out, +} + +type RawFrameTraceObserver = dyn Fn(RawFrameDirection, &str) + Send + Sync + 'static; + +static RAW_FRAME_TRACE_OBSERVER: OnceLock> = OnceLock::new(); + +pub fn install_raw_frame_trace_observer(observer: Arc) { + let _ = RAW_FRAME_TRACE_OBSERVER.set(observer); +} + +pub(crate) fn emit_raw_frame_trace(direction: RawFrameDirection, payload: &str) { + if let Some(observer) = RAW_FRAME_TRACE_OBSERVER.get() { + observer(direction, payload); + } +} diff --git a/shared/rust-bridge/codex-mobile-client/Cargo.toml b/shared/rust-bridge/codex-mobile-client/Cargo.toml index a6d7e225..813a1f6a 100644 --- a/shared/rust-bridge/codex-mobile-client/Cargo.toml +++ b/shared/rust-bridge/codex-mobile-client/Cargo.toml @@ -14,7 +14,6 @@ codex-app-server = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-ipc = { path = "../codex-ipc" } -mobile-log-shared = { path = "../mobile-log-shared" } codex-protocol = { workspace = true } codex-core = { workspace = true } codex-config = { workspace = true } @@ -37,10 +36,8 @@ russh-keys = { workspace = true } futures = { workspace = true } url = { workspace = true } uuid = { workspace = true } -flate2 = { workspace = true } reqwest = { workspace = true } tracing-subscriber = { workspace = true } -arc-swap = { workspace = true } [features] rpc-trace = [] diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs index bcefdd38..1f4f145e 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/app_store.rs @@ -6,6 +6,7 @@ use crate::ffi::shared::{blocking_async, shared_mobile_client, shared_runtime}; use crate::store::{AppSnapshotRecord, AppStoreUpdateRecord, AppThreadSnapshot, AppUpdate}; use crate::types::generated; use crate::types::models::ThreadKey; +use std::collections::VecDeque; use std::sync::Arc; #[derive(uniffi::Object)] @@ -16,14 +17,25 @@ pub struct AppStore { #[derive(uniffi::Object)] pub struct AppStoreSubscription { - pub(crate) rx: std::sync::Mutex>>, + pub(crate) state: std::sync::Mutex>, } +pub(crate) struct AppStoreSubscriptionState { + pub(crate) rx: tokio::sync::broadcast::Receiver, + pub(crate) buffered: VecDeque, +} + +const MAX_COALESCED_STREAMING_TEXT_BYTES: usize = 8 * 1024; + #[cfg(test)] mod tests { + use super::{AppStoreSubscription, AppStoreSubscriptionState}; + use crate::store::{AppStoreReducer, AppStoreUpdateRecord, ThreadStreamingDeltaKind}; + use crate::types::ThreadKey; use crate::types::generated; use codex_app_server_protocol as upstream; use serde_json::json; + use std::collections::VecDeque; fn convert_generated_thread_item( item: generated::ThreadItem, @@ -87,6 +99,101 @@ mod tests { assert_eq!(state.status, upstream::CollabAgentStatus::Running); assert_eq!(state.message.as_deref(), Some("Working")); } + + #[test] + fn app_store_subscription_returns_full_resync_when_updates_lag() { + let reducer = AppStoreReducer::new(); + let subscription = AppStoreSubscription { + state: std::sync::Mutex::new(Some(AppStoreSubscriptionState { + rx: reducer.subscribe(), + buffered: VecDeque::new(), + })), + }; + + for _ in 0..300 { + reducer.set_active_thread(Some(ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread-1".to_string(), + })); + } + + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let update = runtime + .block_on(subscription.next_update()) + .expect("next update should succeed"); + assert!(matches!(update, AppStoreUpdateRecord::FullResync)); + } + + #[test] + fn app_store_subscription_coalesces_contiguous_streaming_deltas() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread-1".to_string(), + }; + let subscription = AppStoreSubscription { + state: std::sync::Mutex::new(Some(AppStoreSubscriptionState { + rx: reducer.subscribe(), + buffered: VecDeque::new(), + })), + }; + + reducer.emit_thread_streaming_delta( + &key, + "assistant-1", + ThreadStreamingDeltaKind::AssistantText, + "hel", + ); + reducer.emit_thread_streaming_delta( + &key, + "assistant-1", + ThreadStreamingDeltaKind::AssistantText, + "lo", + ); + reducer.emit_thread_streaming_delta( + &key, + "assistant-1", + ThreadStreamingDeltaKind::AssistantText, + " world", + ); + + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let update = runtime + .block_on(subscription.next_update()) + .expect("next update should succeed"); + + assert!(matches!( + update, + AppStoreUpdateRecord::ThreadStreamingDelta { + key: emitted_key, + item_id, + kind: crate::store::AppThreadStreamingDeltaKind::AssistantText, + text, + } if emitted_key == key && item_id == "assistant-1" && text == "hello world" + )); + } + + #[test] + fn app_store_subscription_coalesces_refresh_only_updates_into_full_resync() { + let reducer = AppStoreReducer::new(); + let subscription = AppStoreSubscription { + state: std::sync::Mutex::new(Some(AppStoreSubscriptionState { + rx: reducer.subscribe(), + buffered: VecDeque::new(), + })), + }; + + reducer.update_server_health("srv", crate::store::ServerHealthSnapshot::Connected); + reducer.replace_pending_approvals(Vec::new()); + reducer.set_voice_handoff_thread(None); + + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let update = runtime + .block_on(subscription.next_update()) + .expect("next update should succeed"); + + assert!(matches!(update, AppStoreUpdateRecord::FullResync)); + } } #[uniffi::export(async_runtime = "tokio")] @@ -141,7 +248,10 @@ impl AppStore { pub fn subscribe_updates(&self) -> AppStoreSubscription { AppStoreSubscription { - rx: std::sync::Mutex::new(Some(self.inner.subscribe_app_updates())), + state: std::sync::Mutex::new(Some(AppStoreSubscriptionState { + rx: self.inner.subscribe_app_updates(), + buffered: VecDeque::new(), + })), } } @@ -215,8 +325,8 @@ impl AppStore { #[uniffi::export(async_runtime = "tokio")] impl AppStoreSubscription { pub async fn next_update(&self) -> Result { - let mut rx = { - self.rx + let mut state = { + self.state .lock() .unwrap() .take() @@ -225,15 +335,186 @@ impl AppStoreSubscription { ))? }; let result = loop { - match rx.recv().await { + match receive_next_update(&mut state).await { Ok(update) => break Ok(update.into()), - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + break Ok(AppStoreUpdateRecord::FullResync); + } Err(tokio::sync::broadcast::error::RecvError::Closed) => { break Err(ClientError::EventClosed("closed".to_string())); } } }; - *self.rx.lock().unwrap() = Some(rx); + *self.state.lock().unwrap() = Some(state); result } } + +async fn receive_next_update( + state: &mut AppStoreSubscriptionState, +) -> Result { + let first = if let Some(update) = state.buffered.pop_front() { + update + } else { + state.rx.recv().await? + }; + + coalesce_ready_updates(state, first) +} + +fn coalesce_ready_updates( + state: &mut AppStoreSubscriptionState, + mut update: AppUpdate, +) -> Result { + loop { + let next = if let Some(update) = state.buffered.pop_front() { + Some(update) + } else { + match state.rx.try_recv() { + Ok(update) => Some(update), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) => None, + Err(tokio::sync::broadcast::error::TryRecvError::Lagged(skipped)) => { + return Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)); + } + Err(tokio::sync::broadcast::error::TryRecvError::Closed) => None, + } + }; + + let Some(next) = next else { + return Ok(update); + }; + + if let Err(next) = merge_app_update(&mut update, next) { + state.buffered.push_front(next); + return Ok(update); + } + } +} + +fn merge_app_update(current: &mut AppUpdate, next: AppUpdate) -> Result<(), AppUpdate> { + if matches!(current, AppUpdate::FullResync) { + return Ok(()); + } + if matches!(next, AppUpdate::FullResync) { + *current = AppUpdate::FullResync; + return Ok(()); + } + if triggers_snapshot_refresh(current) && triggers_snapshot_refresh(&next) { + *current = AppUpdate::FullResync; + return Ok(()); + } + + match (current, next) { + ( + AppUpdate::ThreadStreamingDelta { + key, + item_id, + kind, + text, + }, + AppUpdate::ThreadStreamingDelta { + key: next_key, + item_id: next_item_id, + kind: next_kind, + text: next_text, + }, + ) if *key == next_key + && *item_id == next_item_id + && *kind == next_kind + && text.len().saturating_add(next_text.len()) <= MAX_COALESCED_STREAMING_TEXT_BYTES => + { + text.push_str(&next_text); + Ok(()) + } + ( + AppUpdate::ThreadStateUpdated { + state, + session_summary, + agent_directory_version, + }, + AppUpdate::ThreadStateUpdated { + state: next_state, + session_summary: next_summary, + agent_directory_version: next_version, + }, + ) if state.key == next_state.key => { + *state = next_state; + *session_summary = next_summary; + *agent_directory_version = next_version; + Ok(()) + } + ( + AppUpdate::ThreadItemUpserted { key, item }, + AppUpdate::ThreadItemUpserted { + key: next_key, + item: next_item, + }, + ) if *key == next_key && item.id == next_item.id => { + *item = next_item; + Ok(()) + } + ( + AppUpdate::ThreadCommandExecutionUpdated { + key, + item_id, + status, + exit_code, + duration_ms, + process_id, + }, + AppUpdate::ThreadCommandExecutionUpdated { + key: next_key, + item_id: next_item_id, + status: next_status, + exit_code: next_exit_code, + duration_ms: next_duration_ms, + process_id: next_process_id, + }, + ) if *key == next_key && *item_id == next_item_id => { + *status = next_status; + *exit_code = next_exit_code; + *duration_ms = next_duration_ms; + *process_id = next_process_id; + Ok(()) + } + ( + AppUpdate::ThreadUpserted { + thread, + session_summary, + agent_directory_version, + }, + AppUpdate::ThreadUpserted { + thread: next_thread, + session_summary: next_summary, + agent_directory_version: next_version, + }, + ) if thread.key == next_thread.key => { + *thread = next_thread; + *session_summary = next_summary; + *agent_directory_version = next_version; + Ok(()) + } + ( + AppUpdate::ActiveThreadChanged { key }, + AppUpdate::ActiveThreadChanged { key: next_key }, + ) => { + *key = next_key; + Ok(()) + } + (current, next) => Err(next), + } +} + +fn triggers_snapshot_refresh(update: &AppUpdate) -> bool { + matches!( + update, + AppUpdate::ServerChanged { .. } + | AppUpdate::ServerRemoved { .. } + | AppUpdate::PendingApprovalsChanged { .. } + | AppUpdate::PendingUserInputsChanged { .. } + | AppUpdate::VoiceSessionChanged + | AppUpdate::RealtimeStarted { .. } + | AppUpdate::RealtimeError { .. } + | AppUpdate::RealtimeClosed { .. } + ) +} diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/logs.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/logs.rs deleted file mode 100644 index e1bd55e4..00000000 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/logs.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::sync::Arc; - -use crate::logging::{LogInput, LogLevelName, LogPipeline, PersistedLogConfig}; - -#[derive(uniffi::Enum, Clone, Copy)] -pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, -} - -#[derive(uniffi::Enum, Clone, Copy)] -pub enum LogSource { - Rust, - Ios, - Android, -} - -#[derive(uniffi::Record, Clone)] -pub struct LogEvent { - pub timestamp_ms: Option, - pub level: LogLevel, - pub source: LogSource, - pub subsystem: String, - pub category: String, - pub message: String, - pub session_id: Option, - pub server_id: Option, - pub thread_id: Option, - pub request_id: Option, - pub payload_json: Option, - pub fields_json: Option, -} - -#[derive(uniffi::Record, Clone)] -pub struct LogConfig { - pub enabled: bool, - pub collector_url: Option, - pub min_level: LogLevel, - pub device_id: Option, - pub device_name: Option, - pub app_version: Option, - pub build: Option, -} - -#[derive(uniffi::Object)] -pub struct Logs { - inner: Arc, -} - -#[uniffi::export(async_runtime = "tokio")] -impl Logs { - #[uniffi::constructor] - pub fn new() -> Self { - Self { - inner: LogPipeline::shared(), - } - } - - pub fn log(&self, event: LogEvent) { - self.inner.log(event.into()); - } - - pub fn configure(&self, config: LogConfig) { - let current = self.inner.config_snapshot(); - self.inner.configure(PersistedLogConfig { - enabled: config.enabled, - collector_url: config.collector_url, - min_level: config.min_level.into(), - device_id: config.device_id.unwrap_or(current.device_id), - device_name: config.device_name, - app_version: config.app_version, - build: config.build, - }); - } - - pub async fn flush(&self) { - self.inner.flush().await; - } -} - -impl From for LogLevelName { - fn from(value: LogLevel) -> Self { - match value { - LogLevel::Trace => Self::Trace, - LogLevel::Debug => Self::Debug, - LogLevel::Info => Self::Info, - LogLevel::Warn => Self::Warn, - LogLevel::Error => Self::Error, - } - } -} - -impl From for LogInput { - fn from(value: LogEvent) -> Self { - Self { - timestamp_ms: value.timestamp_ms, - level: value.level.into(), - source: match value.source { - LogSource::Rust => "rust".to_string(), - LogSource::Ios => "ios".to_string(), - LogSource::Android => "android".to_string(), - }, - subsystem: value.subsystem, - category: value.category, - message: value.message, - session_id: value.session_id, - server_id: value.server_id, - thread_id: value.thread_id, - request_id: value.request_id, - payload_json: value.payload_json, - fields_json: value.fields_json, - } - } -} diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/mod.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/mod.rs index 266527ec..3c7399d9 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/mod.rs @@ -7,7 +7,6 @@ mod app_store; mod discovery; mod errors; -mod logs; mod parser; #[path = "rpc.generated.rs"] mod rpc; @@ -18,7 +17,6 @@ mod ssh; pub use app_store::{AppStore, AppStoreSubscription}; pub use discovery::{DiscoveryBridge, DiscoveryScanSubscription, ServerBridge}; pub use errors::ClientError; -pub use logs::{LogConfig, LogEvent, LogLevel, LogSource, Logs}; pub use parser::MessageParser; pub use rpc::AppServerRpc; pub use ssh::{FfiSshConnectionResult, FfiSshExecResult, SshBridge}; diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/shared.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/shared.rs index 05b49b45..dbd33749 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/shared.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/shared.rs @@ -7,6 +7,7 @@ static SHARED_MOBILE_CLIENT: OnceLock> = OnceLock::new(); pub(crate) fn shared_runtime() -> Arc { SHARED_RUNTIME .get_or_init(|| { + crate::logging::install_tracing_subscriber(); Arc::new( tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/shared/rust-bridge/codex-mobile-client/src/logging/mod.rs b/shared/rust-bridge/codex-mobile-client/src/logging/mod.rs index 8a13c074..41b31c7c 100644 --- a/shared/rust-bridge/codex-mobile-client/src/logging/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/logging/mod.rs @@ -1,39 +1,10 @@ -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufWriter, Write}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::OnceLock; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use arc_swap::ArcSwap; -use flate2::Compression; -use flate2::write::GzEncoder; -use mobile_log_shared::StoredLogEvent; -use reqwest::header::{CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderValue}; -use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, Notify, mpsc, oneshot}; -use tracing::Subscriber; -use tracing_subscriber::Layer; -use tracing_subscriber::layer::Context; -use tracing_subscriber::prelude::*; -use tracing_subscriber::registry::LookupSpan; -use uuid::Uuid; +use tracing::Level; -use crate::ffi::shared::shared_runtime; - -const DEFAULT_QUEUE_CAPACITY: usize = 4_096; -const DEFAULT_MAX_BATCH_BYTES: usize = 256 * 1024; -const DEFAULT_MAX_PENDING_BYTES: u64 = 256 * 1024 * 1024; -const DEFAULT_ROLL_INTERVAL: Duration = Duration::from_secs(1); -const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(60); - -static SHARED_LOG_PIPELINE: OnceLock> = OnceLock::new(); static TRACING_SUBSCRIBER_INSTALLED: OnceLock<()> = OnceLock::new(); -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum LogLevelName { Trace, Debug, @@ -53,1044 +24,127 @@ impl LogLevelName { } } - fn from_str(value: &str) -> Self { - match value.trim().to_ascii_uppercase().as_str() { - "TRACE" => Self::Trace, - "DEBUG" => Self::Debug, - "WARN" | "WARNING" => Self::Warn, - "ERROR" => Self::Error, - _ => Self::Info, - } - } -} - -#[derive(Debug, Clone)] -pub struct LogInput { - pub timestamp_ms: Option, - pub level: LogLevelName, - pub source: String, - pub subsystem: String, - pub category: String, - pub message: String, - pub session_id: Option, - pub server_id: Option, - pub thread_id: Option, - pub request_id: Option, - pub payload_json: Option, - pub fields_json: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PersistedLogConfig { - pub enabled: bool, - pub collector_url: Option, - pub min_level: LogLevelName, - pub device_id: String, - pub device_name: Option, - pub app_version: Option, - pub build: Option, -} - -impl Default for PersistedLogConfig { - fn default() -> Self { - Self { - enabled: false, - collector_url: None, - min_level: LogLevelName::Info, - device_id: Uuid::new_v4().to_string(), - device_name: None, - app_version: None, - build: None, - } - } -} - -#[derive(Debug, Clone)] -pub struct LogPipelineOptions { - pub queue_capacity: usize, - pub max_batch_bytes: usize, - pub max_pending_bytes: u64, - pub roll_interval: Duration, - pub spawn_background_tasks: bool, - pub install_tracing_subscriber: bool, -} - -impl Default for LogPipelineOptions { - fn default() -> Self { - Self { - queue_capacity: DEFAULT_QUEUE_CAPACITY, - max_batch_bytes: DEFAULT_MAX_BATCH_BYTES, - max_pending_bytes: DEFAULT_MAX_PENDING_BYTES, - roll_interval: DEFAULT_ROLL_INTERVAL, - spawn_background_tasks: true, - install_tracing_subscriber: true, - } - } -} - -enum QueueItem { - Event(StoredLogEvent), - Flush(oneshot::Sender<()>), -} - -#[derive(Default)] -struct BatchBuffer { - items: Vec, - raw_bytes: usize, -} - -impl BatchBuffer { - fn is_empty(&self) -> bool { - self.items.is_empty() - } - - fn push(&mut self, event: StoredLogEvent) -> Result<(), serde_json::Error> { - let encoded = serde_json::to_vec(&event)?; - self.raw_bytes += encoded.len() + 1; - self.items.push(event); - Ok(()) - } - - fn should_roll(&self, max_batch_bytes: usize) -> bool { - self.raw_bytes >= max_batch_bytes - } - - fn take(&mut self) -> Self { - let mut next = Self::default(); - std::mem::swap(self, &mut next); - next - } -} - -struct TracingLogLayer { - pipeline: Arc, -} - -impl Layer for TracingLogLayer -where - S: Subscriber + for<'span> LookupSpan<'span>, -{ - fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { - let metadata = event.metadata(); - let target = metadata.target(); - if !target.starts_with("codex") - && !target.starts_with("app_server") - && !target.starts_with("rpc") - && !target.starts_with("store") - { - return; - } - - let mut visitor = FieldVisitor::default(); - event.record(&mut visitor); - let request_id = visitor - .remove("request_id") - .or_else(|| visitor.remove("rpc.request_id")) - .or_else(|| visitor.remove("rpc_request_id")); - let message = visitor - .remove("message") - .unwrap_or_else(|| metadata.name().to_string()); - let fields_json = if visitor.fields.is_empty() { - None - } else { - serde_json::to_string(&visitor.fields).ok() - }; - - self.pipeline.log(LogInput { - timestamp_ms: None, - level: LogLevelName::from_str(metadata.level().as_str()), - source: "rust".to_string(), - subsystem: target.to_string(), - category: metadata.name().to_string(), - message, - session_id: None, - server_id: None, - thread_id: None, - request_id, - payload_json: None, - fields_json, - }); - } -} - -#[derive(Default)] -struct FieldVisitor { - fields: BTreeMap, -} - -impl FieldVisitor { - fn remove(&mut self, key: &str) -> Option { - self.fields.remove(key) - } -} - -impl tracing::field::Visit for FieldVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - self.fields - .insert(field.name().to_string(), format!("{value:?}")); - } - - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - self.fields - .insert(field.name().to_string(), value.to_string()); - } - - fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { - self.fields - .insert(field.name().to_string(), value.to_string()); - } - - fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { - self.fields - .insert(field.name().to_string(), value.to_string()); - } - - fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { - self.fields - .insert(field.name().to_string(), value.to_string()); - } -} - -pub struct LogPipeline { - options: LogPipelineOptions, - queue: mpsc::Sender, - config: ArcSwap, - config_path: PathBuf, - pending_dir: PathBuf, - upload_notify: Notify, - upload_lock: Mutex<()>, - dropped_events: AtomicU64, - http: reqwest::Client, -} - -impl LogPipeline { - fn bootstrap(base_dir: PathBuf, options: LogPipelineOptions) -> Arc { - let rt = shared_runtime(); - let spool_dir = base_dir.join("log-spool"); - let pending_dir = spool_dir.join("pending"); - let config_path = spool_dir.join("config.json"); - let _ = std::fs::create_dir_all(&pending_dir); - let config = load_config(&config_path); - - // Debug: write spool init state to a file so we can verify bootstrap ran - let debug_path = spool_dir.join("_debug_init.txt"); - let _ = std::fs::write( - &debug_path, - format!( - "bootstrap at {}\nbase_dir={}\nconfig_path={}\nenabled={}\ncollector_url={:?}\ndevice_id={}\n", - now_ms(), - base_dir.display(), - config_path.display(), - config.enabled, - config.collector_url, - config.device_id, - ), - ); - - let (tx, rx) = mpsc::channel(options.queue_capacity); - - let pipeline = Arc::new(Self { - options: options.clone(), - queue: tx, - config: ArcSwap::from_pointee(config), - config_path, - pending_dir, - upload_notify: Notify::new(), - upload_lock: Mutex::new(()), - dropped_events: AtomicU64::new(0), - http: reqwest::Client::new(), - }); - - if options.spawn_background_tasks { - let writer = Arc::clone(&pipeline); - rt.spawn(async move { - writer.run_writer(rx).await; - }); - - let uploader = Arc::clone(&pipeline); - rt.spawn(async move { - uploader.run_uploader().await; - }); - - // Periodically re-read config from disk so external edits are picked up - let config_reloader = Arc::clone(&pipeline); - let debug_spool_dir = spool_dir.clone(); - rt.spawn(async move { - let mut tick = 0u64; - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - tick += 1; - let fresh = load_config(&config_reloader.config_path); - let current = config_reloader.config.load_full(); - - // Debug: write reload state every tick - let _ = std::fs::write( - debug_spool_dir.join("_debug_reload.txt"), - format!( - "tick={}\nconfig_path={}\nfresh.enabled={}\ncurrent.enabled={}\nfresh.collector_url={:?}\nfresh.device_id={}\n", - tick, - config_reloader.config_path.display(), - fresh.enabled, - current.enabled, - fresh.collector_url, - fresh.device_id, - ), - ); - - if fresh.enabled != current.enabled - || fresh.collector_url != current.collector_url - || fresh.min_level != current.min_level - || fresh.device_id != current.device_id - { - config_reloader.config.store(Arc::new(fresh)); - config_reloader.upload_notify.notify_waiters(); - } - } - }); - } - - if options.install_tracing_subscriber { - install_tracing_subscriber(Arc::clone(&pipeline)); - } - - pipeline - } - - pub fn shared() -> Arc { - SHARED_LOG_PIPELINE - .get_or_init(|| Self::bootstrap(resolve_codex_home(), LogPipelineOptions::default())) - .clone() - } - - #[cfg(test)] - fn new_for_tests(base_dir: PathBuf, options: LogPipelineOptions) -> Arc { - Self::bootstrap(base_dir, options) - } - - pub fn config_snapshot(&self) -> PersistedLogConfig { - (*self.config.load_full()).clone() - } - - pub fn configure(&self, mut next: PersistedLogConfig) { - let current = self.config_snapshot(); - if next.device_id.trim().is_empty() { - next.device_id = current.device_id; - } - if next.device_name.as_deref().is_none_or(str::is_empty) { - next.device_name = current.device_name; - } - if next.app_version.as_deref().is_none_or(str::is_empty) { - next.app_version = current.app_version; - } - if next.build.as_deref().is_none_or(str::is_empty) { - next.build = current.build; - } - - next.collector_url = clean_opt(next.collector_url); - next.device_name = clean_opt(next.device_name); - next.app_version = clean_opt(next.app_version); - next.build = clean_opt(next.build); - - // Env vars override - if let Ok(url) = std::env::var("LOG_COLLECTOR_URL") { - if !url.is_empty() { - next.collector_url = Some(url); - } - } - if next.collector_url.is_some() { - next.enabled = true; - } - - if let Ok(json) = serde_json::to_vec_pretty(&next) { - let _ = std::fs::write(&self.config_path, json); - } - self.config.store(Arc::new(next)); - self.upload_notify.notify_waiters(); - } - - pub fn log(&self, input: LogInput) { - let config = self.config.load_full(); - - // Debug: write to file so we can verify log() is actually being called - let debug_path = self - .pending_dir - .parent() - .map(|p| p.join("_debug_log_calls.txt")); - if let Some(ref path) = debug_path { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!( - f, - "log() called: enabled={} level={:?} min={:?} msg={}", - config.enabled, - input.level, - config.min_level, - &input.message[..input.message.len().min(80)] - ); - } - } - - if !config.enabled || input.level < config.min_level { - return; - } - - let event = normalize_event(&input, &config); - if self.queue.try_send(QueueItem::Event(event)).is_err() { - self.dropped_events.fetch_add(1, Ordering::Relaxed); - return; - } - - let dropped = self.dropped_events.swap(0, Ordering::Relaxed); - if dropped > 0 { - let summary = synthetic_event( - LogLevelName::Warn, - "logging", - "backpressure", - format!("dropped {dropped} log event(s) while queue was full"), - Some(serde_json::json!({ "dropped": dropped }).to_string()), - &config, - ); - if self.queue.try_send(QueueItem::Event(summary)).is_err() { - self.dropped_events.fetch_add(dropped, Ordering::Relaxed); - } - } - } - - pub async fn flush(&self) { - let (tx, rx) = oneshot::channel(); - let _ = self.queue.send(QueueItem::Flush(tx)).await; - let _ = rx.await; - let _guard = self.upload_lock.lock().await; - let _ = self.process_pending_uploads().await; - } - - async fn run_writer(self: Arc, mut rx: mpsc::Receiver) { - // Debug: confirm writer task started - if let Some(spool) = self.pending_dir.parent() { - let _ = std::fs::write( - spool.join("_debug_writer_started.txt"), - format!("writer started at {}\n", now_ms()), - ); - } - let mut batch = BatchBuffer::default(); - let mut recv_count: u64 = 0; - loop { - let maybe_item = if batch.is_empty() { - rx.recv().await - } else { - match tokio::time::timeout(self.options.roll_interval, rx.recv()).await { - Ok(item) => item, - Err(_) => { - let batch_len = batch.items.len(); - if let Some(spool) = self.pending_dir.parent() { - let _ = std::fs::write( - spool.join("_debug_writer_roll.txt"), - format!( - "roll timeout fired at {} recv_count={} batch_items={}\n", - now_ms(), - recv_count, - batch_len - ), - ); - } - let result = self.persist_batch(batch.take()).await; - if let Some(spool) = self.pending_dir.parent() { - let _ = std::fs::write( - spool.join("_debug_persist_result.txt"), - format!( - "persist_batch result: {:?} items={}\npending_dir={}\n", - result, - batch_len, - self.pending_dir.display() - ), - ); - } - continue; - } - } - }; - - let Some(item) = maybe_item else { - if !batch.is_empty() { - let _ = self.persist_batch(batch.take()).await; - } - break; - }; - - match item { - QueueItem::Event(event) => { - recv_count += 1; - if recv_count <= 3 { - if let Some(spool) = self.pending_dir.parent() { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(spool.join("_debug_writer_recv.txt")) - { - let _ = writeln!( - f, - "recv #{} msg={}", - recv_count, - &event.message[..event.message.len().min(60)] - ); - } - } - } - if batch.push(event).is_err() { - continue; - } - if batch.should_roll(self.options.max_batch_bytes) { - let _ = self.persist_batch(batch.take()).await; - } - } - QueueItem::Flush(done) => { - if !batch.is_empty() { - let _ = self.persist_batch(batch.take()).await; - } - let _ = done.send(()); - } - } - } - } - - async fn persist_batch(&self, batch: BatchBuffer) -> std::io::Result<()> { - if batch.items.is_empty() { - return Ok(()); - } - - let pending_dir = self.pending_dir.clone(); - let batch_id = format!("{}-{}", now_ms(), Uuid::new_v4()); - let path = pending_dir.join(format!("{batch_id}.ndjson.gz")); - let temp_path = pending_dir.join(format!("{batch_id}.tmp")); - - tokio::task::spawn_blocking(move || -> std::io::Result<()> { - let file = File::create(&temp_path)?; - let writer = BufWriter::new(file); - let mut encoder = GzEncoder::new(writer, Compression::default()); - for item in &batch.items { - serde_json::to_writer(&mut encoder, item).map_err(std::io::Error::other)?; - encoder.write_all(b"\n")?; - } - let writer = encoder.finish()?; - writer.into_inner()?.sync_all()?; - std::fs::rename(temp_path, path)?; - Ok(()) - }) - .await - .map_err(|err| std::io::Error::other(format!("batch writer join error: {err}")))??; - - let deleted = prune_pending_dir(self.pending_dir.clone(), self.options.max_pending_bytes) - .await - .unwrap_or(0); - if deleted > 0 { - self.log(LogInput { - timestamp_ms: None, - level: LogLevelName::Warn, - source: "rust".to_string(), - subsystem: "logging".to_string(), - category: "spool".to_string(), - message: format!( - "deleted {deleted} bytes from pending spool after exceeding disk cap" - ), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json: Some(serde_json::json!({ "deleted_bytes": deleted }).to_string()), - }); - } - - self.upload_notify.notify_waiters(); - Ok(()) - } - - async fn run_uploader(self: Arc) { - let mut attempts = 0u32; - - loop { - let _guard = self.upload_lock.lock().await; - let result = self.process_pending_uploads().await; - drop(_guard); - - match result { - Ok(processed) if processed > 0 => { - self.write_upload_status("success", format!("processed={processed}")); - attempts = 0; - continue; - } - Ok(_) => { - let config = self.config.load_full(); - let detail = if !config.enabled { - ("disabled", "logging disabled".to_string()) - } else if config.collector_url.is_none() { - ("disabled", "collector_url missing".to_string()) - } else { - ("idle", "no pending uploads".to_string()) - }; - self.write_upload_status(detail.0, detail.1); - attempts = 0; - self.upload_notify.notified().await; - } - Err(err) => { - self.write_upload_status("error", err); - attempts = attempts.saturating_add(1); - let backoff = backoff_delay(attempts); - tokio::select! { - _ = self.upload_notify.notified() => {} - _ = tokio::time::sleep(backoff) => {} - } - } - } - } - } - - async fn process_pending_uploads(&self) -> Result { - let config = self.config.load_full(); - if !config.enabled { - return Ok(0); - } - let Some(base_url) = config.collector_url.as_ref() else { - return Ok(0); - }; - - let files = pending_batch_paths(&self.pending_dir) - .await - .map_err(|e| e.to_string())?; - if files.is_empty() { - return Ok(0); - } - - let mut processed = 0usize; - for path in files { - upload_file(&self.http, &path, base_url, &config.device_id).await?; - processed += 1; + fn into_tracing(self) -> Level { + match self { + Self::Trace => Level::TRACE, + Self::Debug => Level::DEBUG, + Self::Info => Level::INFO, + Self::Warn => Level::WARN, + Self::Error => Level::ERROR, } - - Ok(processed) - } - - fn write_upload_status(&self, state: &str, detail: String) { - let Some(spool_dir) = self.pending_dir.parent() else { - return; - }; - let config = self.config.load_full(); - let pending_count = std::fs::read_dir(&self.pending_dir) - .ok() - .map(|entries| entries.filter_map(Result::ok).count()) - .unwrap_or_default(); - let _ = std::fs::write( - spool_dir.join("_debug_upload_status.txt"), - format!( - "timestamp_ms={}\nstate={}\ndetail={}\ncollector_url={}\ndevice_id={}\npending_count={}\n", - now_ms(), - state, - detail, - config.collector_url.as_deref().unwrap_or(""), - config.device_id, - pending_count, - ), - ); } } -pub(crate) fn log_rust( - level: LogLevelName, - subsystem: impl Into, - category: impl Into, - message: impl Into, - fields_json: Option, -) { - LogPipeline::shared().log(LogInput { - timestamp_ms: None, - level, - source: "rust".to_string(), - subsystem: subsystem.into(), - category: category.into(), - message: message.into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json, - }); -} - -fn install_tracing_subscriber(pipeline: Arc) { +pub(crate) fn install_tracing_subscriber() { TRACING_SUBSCRIBER_INSTALLED.get_or_init(|| { - let subscriber = tracing_subscriber::registry().with(TracingLogLayer { pipeline }); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .compact() + .with_target(true) + .with_max_level(Level::TRACE) + .finish(); let _ = tracing::subscriber::set_global_default(subscriber); }); } -fn normalize_event(input: &LogInput, config: &PersistedLogConfig) -> StoredLogEvent { - StoredLogEvent { - timestamp_ms: input.timestamp_ms.unwrap_or_else(now_ms), - level: input.level.as_str().to_string(), - source: clean_non_empty(&input.source).unwrap_or_else(|| "rust".to_string()), - platform: platform_name().to_string(), - subsystem: clean_non_empty(&input.subsystem).unwrap_or_else(|| "app".to_string()), - category: clean_non_empty(&input.category).unwrap_or_else(|| "default".to_string()), - message: input.message.trim().to_string(), - session_id: clean_opt(input.session_id.clone()), - server_id: clean_opt(input.server_id.clone()), - thread_id: clean_opt(input.thread_id.clone()), - request_id: clean_opt(input.request_id.clone()), - payload_json: clean_opt(input.payload_json.clone()), - fields_json: clean_opt(input.fields_json.clone()), - device_id: config.device_id.clone(), - device_name: config - .device_name - .clone() - .or_else(default_device_name) - .unwrap_or_else(|| platform_name().to_string()), - app_version: config.app_version.clone(), - build: config.build.clone(), - process_id: std::process::id(), - } -} - -fn synthetic_event( +pub(crate) fn log_rust( level: LogLevelName, subsystem: impl Into, category: impl Into, message: impl Into, fields_json: Option, - config: &PersistedLogConfig, -) -> StoredLogEvent { - normalize_event( - &LogInput { - timestamp_ms: None, - level, - source: "rust".to_string(), - subsystem: subsystem.into(), - category: category.into(), - message: message.into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json, - }, - config, - ) -} - -fn load_config(path: &Path) -> PersistedLogConfig { - let mut config: PersistedLogConfig = std::fs::read(path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - .unwrap_or_default(); - - // Env vars override file-based config - if let Ok(url) = std::env::var("LOG_COLLECTOR_URL") { - if !url.is_empty() { - config.collector_url = Some(url); +) { + install_tracing_subscriber(); + + let subsystem = subsystem.into(); + let category = category.into(); + let message = message.into(); + let fields_json = fields_json.filter(|value| !value.trim().is_empty()); + + match (level.into_tracing(), fields_json.as_deref()) { + (Level::TRACE, Some(fields_json)) => { + tracing::event!( + target: "mobile", + Level::TRACE, + subsystem = %subsystem, + category = %category, + fields_json = %fields_json, + "{message}" + ); } - } - if config.collector_url.is_some() { - config.enabled = true; - } - - config -} - -async fn pending_batch_paths(pending_dir: &Path) -> std::io::Result> { - let pending_dir = pending_dir.to_path_buf(); - tokio::task::spawn_blocking(move || -> std::io::Result> { - let mut entries: Vec = std::fs::read_dir(&pending_dir)? - .filter_map(|entry| entry.ok().map(|entry| entry.path())) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("gz")) - .collect(); - entries.sort(); - Ok(entries) - }) - .await - .map_err(|err| std::io::Error::other(format!("pending dir join error: {err}")))? -} - -async fn prune_pending_dir(pending_dir: PathBuf, max_pending_bytes: u64) -> std::io::Result { - tokio::task::spawn_blocking(move || -> std::io::Result { - let mut entries = Vec::new(); - let mut total_size = 0u64; - for entry in std::fs::read_dir(&pending_dir)? { - let path = entry?.path(); - let metadata = std::fs::metadata(&path)?; - if !metadata.is_file() { - continue; - } - total_size += metadata.len(); - entries.push(( - path, - metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), - metadata.len(), - )); + (Level::DEBUG, Some(fields_json)) => { + tracing::event!( + target: "mobile", + Level::DEBUG, + subsystem = %subsystem, + category = %category, + fields_json = %fields_json, + "{message}" + ); } - - if total_size <= max_pending_bytes { - return Ok(0); + (Level::INFO, Some(fields_json)) => { + tracing::event!( + target: "mobile", + Level::INFO, + subsystem = %subsystem, + category = %category, + fields_json = %fields_json, + "{message}" + ); } - - entries.sort_by_key(|(_, modified, _)| *modified); - let mut deleted = 0u64; - for (path, _, len) in entries { - if total_size <= max_pending_bytes { - break; - } - if std::fs::remove_file(&path).is_ok() { - total_size = total_size.saturating_sub(len); - deleted += len; - } + (Level::WARN, Some(fields_json)) => { + tracing::event!( + target: "mobile", + Level::WARN, + subsystem = %subsystem, + category = %category, + fields_json = %fields_json, + "{message}" + ); } - Ok(deleted) - }) - .await - .map_err(|err| std::io::Error::other(format!("spool prune join error: {err}")))? -} - -async fn upload_file( - http: &reqwest::Client, - path: &Path, - base_url: &str, - device_id: &str, -) -> Result<(), String> { - let batch_id = path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.trim_end_matches(".ndjson.gz").to_string()) - .ok_or_else(|| format!("invalid batch file name: {}", path.display()))?; - let body = tokio::fs::read(path) - .await - .map_err(|err| format!("failed to read {}: {err}", path.display()))?; - - let mut headers = HeaderMap::new(); - headers.insert("X-Batch-Id", header_value(&batch_id)?); - headers.insert("X-Device-Id", header_value(device_id)?); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("application/x-ndjson"), - ); - headers.insert(CONTENT_ENCODING, HeaderValue::from_static("gzip")); - - let url = format!("{}/v1/logs", base_url.trim_end_matches('/')); - let response = http - .post(url) - .headers(headers) - .body(body) - .send() - .await - .map_err(|err| format!("upload failed for {batch_id}: {err}"))?; - - if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(format!( - "collector rejected {batch_id} with status {status}: {body}", - )); - } - - tokio::fs::remove_file(path) - .await - .map_err(|err| format!("failed to remove uploaded batch {}: {err}", path.display()))?; - Ok(()) -} - -fn header_value(value: &str) -> Result { - HeaderValue::from_str(value).map_err(|err| format!("invalid header value: {err}")) -} - -fn backoff_delay(attempt: u32) -> Duration { - let capped = attempt.min(6); - let base = 1u64 << capped; - let jitter = (attempt as u64 * 137) % 700; - Duration::from_secs(base) - .saturating_add(Duration::from_millis(jitter)) - .min(DEFAULT_MAX_BACKOFF) -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as i64) - .unwrap_or_default() -} - -fn clean_opt(value: Option) -> Option { - value.as_deref().and_then(clean_non_empty) -} - -fn clean_non_empty(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn resolve_codex_home() -> PathBuf { - let mut candidates = Vec::new(); - - if let Ok(existing) = std::env::var("CODEX_HOME") - && !existing.is_empty() - { - candidates.push(PathBuf::from(existing)); - } - - if let Ok(home) = std::env::var("HOME") - && !home.is_empty() - { - #[cfg(target_os = "ios")] - { - candidates.push( - PathBuf::from(&home) - .join("Library") - .join("Application Support") - .join("codex"), + (Level::ERROR, Some(fields_json)) => { + tracing::event!( + target: "mobile", + Level::ERROR, + subsystem = %subsystem, + category = %category, + fields_json = %fields_json, + "{message}" ); } - - candidates.push(PathBuf::from(home).join(".codex")); - } - - if let Ok(tmpdir) = std::env::var("TMPDIR") - && !tmpdir.is_empty() - { - candidates.push(PathBuf::from(tmpdir).join("codex-home")); - } - - for candidate in candidates { - if std::fs::create_dir_all(&candidate).is_ok() { - unsafe { - std::env::set_var("CODEX_HOME", &candidate); - } - return candidate; + (Level::TRACE, None) => { + tracing::event!(target: "mobile", Level::TRACE, subsystem = %subsystem, category = %category, "{message}"); + } + (Level::DEBUG, None) => { + tracing::event!(target: "mobile", Level::DEBUG, subsystem = %subsystem, category = %category, "{message}"); + } + (Level::INFO, None) => { + tracing::event!(target: "mobile", Level::INFO, subsystem = %subsystem, category = %category, "{message}"); + } + (Level::WARN, None) => { + tracing::event!(target: "mobile", Level::WARN, subsystem = %subsystem, category = %category, "{message}"); + } + (Level::ERROR, None) => { + tracing::event!(target: "mobile", Level::ERROR, subsystem = %subsystem, category = %category, "{message}"); } - } - - let fallback = std::env::temp_dir().join("codex-home"); - let _ = std::fs::create_dir_all(&fallback); - unsafe { - std::env::set_var("CODEX_HOME", &fallback); - } - fallback -} - -fn platform_name() -> &'static str { - #[cfg(target_os = "ios")] - { - return "ios"; - } - #[cfg(target_os = "android")] - { - return "android"; - } - #[cfg(not(any(target_os = "ios", target_os = "android")))] - { - "host" } } -fn default_device_name() -> Option { - clean_opt(std::env::var("HOSTNAME").ok()) +pub(crate) fn install_ipc_wire_trace_logger() { + install_tracing_subscriber(); } #[cfg(test)] mod tests { - use super::*; - - fn temp_dir(name: &str) -> PathBuf { - let path = - std::env::temp_dir().join(format!("codex-mobile-log-test-{name}-{}", Uuid::new_v4())); - std::fs::create_dir_all(&path).expect("temp dir"); - path - } - - #[tokio::test(flavor = "current_thread")] - async fn flush_writes_gzipped_ndjson_batch() { - let base_dir = temp_dir("flush"); - let pipeline = LogPipeline::new_for_tests( - base_dir.clone(), - LogPipelineOptions { - spawn_background_tasks: true, - install_tracing_subscriber: false, - ..LogPipelineOptions::default() - }, - ); - pipeline.configure(PersistedLogConfig { - enabled: true, - collector_url: None, - min_level: LogLevelName::Debug, - device_id: "device-1".into(), - device_name: Some("test-device".into()), - app_version: Some("1.0".into()), - build: Some("42".into()), - }); - - pipeline.log(LogInput { - timestamp_ms: Some(123), - level: LogLevelName::Info, - source: "ios".into(), - subsystem: "test".into(), - category: "flush".into(), - message: "hello".into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: Some("{\"ok\":true}".into()), - fields_json: None, - }); - pipeline.flush().await; - - let files = pending_batch_paths(&base_dir.join("log-spool").join("pending")) - .await - .expect("pending files"); - assert_eq!(files.len(), 1); - } + use super::LogLevelName; #[test] - fn normalize_event_attaches_metadata() { - let config = PersistedLogConfig { - enabled: true, - collector_url: None, - min_level: LogLevelName::Trace, - device_id: "device-1".into(), - device_name: Some("phone".into()), - app_version: Some("1.0".into()), - build: Some("7".into()), - }; - let event = normalize_event( - &LogInput { - timestamp_ms: Some(55), - level: LogLevelName::Error, - source: "android".into(), - subsystem: "voice".into(), - category: "".into(), - message: "boom".into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json: None, - }, - &config, - ); - - assert_eq!(event.device_id, "device-1"); - assert_eq!(event.device_name, "phone"); - assert_eq!(event.level, "ERROR"); - assert_eq!(event.category, "default"); + fn log_level_name_strings_match_expected_format() { + assert_eq!(LogLevelName::Trace.as_str(), "TRACE"); + assert_eq!(LogLevelName::Debug.as_str(), "DEBUG"); + assert_eq!(LogLevelName::Info.as_str(), "INFO"); + assert_eq!(LogLevelName::Warn.as_str(), "WARN"); + assert_eq!(LogLevelName::Error.as_str(), "ERROR"); } } diff --git a/shared/rust-bridge/codex-mobile-client/src/mobile_client_impl.rs b/shared/rust-bridge/codex-mobile-client/src/mobile_client_impl.rs index ee5009f3..e9fedba4 100644 --- a/shared/rust-bridge/codex-mobile-client/src/mobile_client_impl.rs +++ b/shared/rust-bridge/codex-mobile-client/src/mobile_client_impl.rs @@ -1,6 +1,6 @@ #[cfg(target_os = "android")] use futures::FutureExt; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::future::Future; #[cfg(target_os = "android")] use std::panic::AssertUnwindSafe; @@ -16,21 +16,27 @@ use crate::session::connection::{ }; use crate::session::events::{EventProcessor, UiEvent}; use crate::ssh::{SshBootstrapResult, SshClient, SshCredentials}; -use crate::store::{AppSnapshot, AppStoreReducer, AppUpdate, ServerHealthSnapshot, ThreadSnapshot}; +use crate::store::updates::ThreadStreamingDeltaKind; +use crate::store::{ + AppSnapshot, AppStoreReducer, AppUpdate, QueuedFollowUpPreview, ServerHealthSnapshot, + ThreadSnapshot, +}; use crate::transport::{RpcError, TransportError}; use crate::types::{ ApprovalDecisionValue, PendingApproval, PendingUserInputAnswer, PendingUserInputRequest, - ThreadInfo, ThreadKey, generated, + ThreadInfo, ThreadKey, ThreadSummaryStatus, generated, }; +use crate::uniffi_shared::AppOperationStatus; use codex_app_server_protocol as upstream; use codex_ipc::{ ClientStatus, CommandExecutionApprovalDecision, ConversationStreamApplyError, - ExternalResumeThreadParams, FileChangeApprovalDecision, IpcClient, IpcClientConfig, - ProjectedApprovalKind, ProjectedApprovalRequest, ProjectedUserInputRequest, StreamChange, - ThreadFollowerCommandApprovalDecisionParams, ThreadFollowerFileApprovalDecisionParams, - ThreadFollowerStartTurnParams, ThreadFollowerSubmitUserInputParams, - ThreadStreamStateChangedParams, TypedBroadcast, apply_stream_change_to_conversation_state, - project_conversation_state, seed_conversation_state_from_thread, + ExternalResumeThreadParams, FileChangeApprovalDecision, ImmerOp, ImmerPatch, ImmerPathSegment, + IpcClient, IpcClientConfig, ProjectedApprovalKind, ProjectedApprovalRequest, + ProjectedUserInputRequest, StreamChange, ThreadFollowerCommandApprovalDecisionParams, + ThreadFollowerFileApprovalDecisionParams, ThreadFollowerStartTurnParams, + ThreadFollowerSubmitUserInputParams, ThreadStreamStateChangedParams, TypedBroadcast, + apply_stream_change_to_conversation_state, project_conversation_request_state, + project_conversation_state, project_conversation_turn, seed_conversation_state_from_thread, }; /// Top-level entry point for platform code (iOS / Android). @@ -55,6 +61,7 @@ struct OAuthCallbackTunnel { impl MobileClient { /// Create a new `MobileClient`. pub fn new() -> Self { + crate::logging::install_ipc_wire_trace_logger(); let event_processor = Arc::new(EventProcessor::new()); let app_store = Arc::new(AppStoreReducer::new()); spawn_store_listener(Arc::clone(&app_store), event_processor.subscribe()); @@ -616,6 +623,21 @@ impl MobileClient { params: generated::TurnStartParams, ) -> Result<(), RpcError> { let session = self.get_session(server_id)?; + let thread_key = ThreadKey { + server_id: server_id.to_string(), + thread_id: params.thread_id.clone(), + }; + let queued_preview = self + .snapshot_thread(&thread_key) + .ok() + .filter(|thread| { + thread.active_turn_id.is_some() || thread.info.status == ThreadSummaryStatus::Active + }) + .and_then(|_| queued_follow_up_preview_from_inputs(¶ms.input)); + if let Some(preview) = queued_preview.clone() { + self.app_store + .enqueue_thread_follow_up_preview(&thread_key, preview); + } let direct_params = params.clone(); if let Some(ipc_client) = session.ipc_client() { @@ -649,6 +671,10 @@ impl MobileClient { return Ok(()); } Err(error) => { + if let Some(preview) = queued_preview.as_ref() { + self.app_store + .remove_thread_follow_up_preview(&thread_key, &preview.id); + } warn!( "MobileClient: IPC follower start turn failed for {} thread {}: {}", server_id, thread_id, error @@ -662,7 +688,13 @@ impl MobileClient { let response = self .generated_turn_start(server_id, direct_params) .await - .map_err(|error| RpcError::Deserialization(error.to_string()))?; + .map_err(|error| { + if let Some(preview) = queued_preview.as_ref() { + self.app_store + .remove_thread_follow_up_preview(&thread_key, &preview.id); + } + RpcError::Deserialization(error.to_string()) + })?; self.reconcile_public_rpc("turn/start", server_id, Some(&reconcile_params), &response) .await } @@ -1066,7 +1098,7 @@ impl MobileClient { let app_store = Arc::clone(&self.app_store); let loop_server_id = server_id.clone(); Self::spawn_detached(async move { - let mut stream_cache: HashMap = HashMap::new(); + let mut stream_cache: HashMap = HashMap::new(); loop { match broadcasts.recv().await { Ok(TypedBroadcast::ThreadStreamStateChanged(params)) => { @@ -1075,7 +1107,7 @@ impl MobileClient { StreamChange::Patches { .. } => "patches", }; debug!( - "IPC in: ThreadStreamStateChanged server={} thread={} version={} change={}", + "IPC in: ThreadStreamStateChanged server={} thread={} protocol_version={} change={}", loop_server_id, params.conversation_id, params.version, change_type ); @@ -1086,28 +1118,6 @@ impl MobileClient { ¶ms, ) { Ok(()) => {} - Err(StreamHandleError::VersionGap) => { - debug!( - "IPC: version gap for thread={}, reseeding stream cache from RPC", - params.conversation_id - ); - stream_cache.remove(¶ms.conversation_id); - if let Err(e) = recover_ipc_stream_cache_from_app_server( - Arc::clone(&session), - Arc::clone(&app_store), - &mut stream_cache, - &loop_server_id, - ¶ms.conversation_id, - params.version, - ) - .await - { - warn!( - "IPC: RPC cache recovery failed for thread {}: {}", - params.conversation_id, e - ); - } - } Err(StreamHandleError::NoCachedState) => { debug!( "IPC: no cached state for thread={}, seeding stream cache from RPC", @@ -1119,7 +1129,6 @@ impl MobileClient { &mut stream_cache, &loop_server_id, ¶ms.conversation_id, - params.version, ) .await { @@ -1141,7 +1150,6 @@ impl MobileClient { &mut stream_cache, &loop_server_id, ¶ms.conversation_id, - params.version, ) .await { @@ -1163,7 +1171,6 @@ impl MobileClient { &mut stream_cache, &loop_server_id, ¶ms.conversation_id, - params.version, ) .await { @@ -1934,12 +1941,54 @@ pub fn copy_thread_runtime_fields(source: &ThreadSnapshot, target: &mut ThreadSn if target.reasoning_effort.is_none() { target.reasoning_effort = source.reasoning_effort.clone(); } + if target.queued_follow_ups.is_empty() { + target.queued_follow_ups = source.queued_follow_ups.clone(); + } target.context_tokens_used = source.context_tokens_used; target.model_context_window = source.model_context_window; target.rate_limits = source.rate_limits.clone(); target.realtime_session_id = source.realtime_session_id.clone(); } +fn queued_follow_up_preview_from_inputs( + inputs: &[generated::UserInput], +) -> Option { + let mut text_parts: Vec = Vec::new(); + let mut attachment_count = 0usize; + + for input in inputs { + match input { + generated::UserInput::Text { text, .. } => { + let trimmed = text.trim(); + if !trimmed.is_empty() { + text_parts.push(trimmed.to_string()); + } + } + generated::UserInput::Image { .. } | generated::UserInput::LocalImage { .. } => { + attachment_count += 1; + } + generated::UserInput::Skill { .. } | generated::UserInput::Mention { .. } => {} + } + } + + let text = if !text_parts.is_empty() { + text_parts.join("\n") + } else if attachment_count > 0 { + if attachment_count == 1 { + "1 image attachment".to_string() + } else { + format!("{attachment_count} image attachments") + } + } else { + return None; + }; + + Some(QueuedFollowUpPreview { + id: uuid::Uuid::new_v4().to_string(), + text, + }) +} + fn remote_oauth_callback_port(auth_url: &str) -> Result { let parsed = Url::parse(auth_url).map_err(|error| { RpcError::Deserialization(format!("invalid auth URL for remote OAuth: {error}")) @@ -2140,10 +2189,9 @@ fn upsert_thread_snapshot_from_app_server_thread( async fn recover_ipc_stream_cache_from_app_server( session: Arc, app_store: Arc, - cache: &mut HashMap, + cache: &mut HashMap, server_id: &str, thread_id: &str, - version: u32, ) -> Result<(), RpcError> { let key = ThreadKey { server_id: server_id.to_string(), @@ -2177,7 +2225,7 @@ async fn recover_ipc_stream_cache_from_app_server( &pending_approvals, &pending_user_inputs, ); - cache.insert(thread_id.to_string(), (version, conversation_state)); + cache.insert(thread_id.to_string(), conversation_state); Ok(()) } @@ -2489,16 +2537,790 @@ fn seed_ipc_user_input_request(request: &PendingUserInputRequest) -> serde_json: // -- IPC stream state change handler -- +#[derive(Debug, Default)] +struct IncrementalIpcPatchSummary { + affected_turn_indices: Vec, + requests_changed: bool, + latest_model_changed: bool, + latest_reasoning_effort_changed: bool, + updated_at_changed: bool, +} + +#[derive(Debug, Clone)] +struct IncrementalProjectedTurn { + turn_index: usize, + items: Vec, +} + +#[derive(Debug, Clone)] +struct IncrementalStreamingDelta { + item_id: String, + kind: ThreadStreamingDeltaKind, + text: String, +} + +#[derive(Debug, Clone)] +struct IncrementalCommandExecutionUpdate { + item_id: String, + status: AppOperationStatus, + exit_code: Option, + duration_ms: Option, + process_id: Option, +} + +#[derive(Debug, Clone)] +enum IncrementalThreadEvent { + Streaming(IncrementalStreamingDelta), + CommandExecutionUpdated(IncrementalCommandExecutionUpdate), + ItemUpsert(crate::conversation::ConversationItem), +} + +#[derive(Debug, Clone)] +enum IncrementalTurnMutation { + Unchanged, + Patched { + projected_turn: IncrementalProjectedTurn, + events: Vec, + }, + Replace(IncrementalProjectedTurn), +} + +#[derive(Debug, Clone)] +struct IncrementalThreadMutation { + turn_mutations: Vec, + active_turn_id: Option, + updated_at: Option, + latest_model: Option, + latest_reasoning_effort: Option, + thread_status: ThreadSummaryStatus, +} + +#[derive(Debug)] +struct IncrementalMutationResult { + requires_thread_upsert: bool, + emitted_deltas: Vec, + emitted_command_updates: Vec, + emitted_item_upserts: Vec, + emit_thread_state_update: bool, +} + +fn summarize_incremental_ipc_patches(patches: &[ImmerPatch]) -> Option { + let mut affected_turn_indices = BTreeSet::new(); + let mut summary = IncrementalIpcPatchSummary::default(); + + for patch in patches { + match patch.path.as_slice() { + [ImmerPathSegment::Key(key)] if key == "requests" => { + summary.requests_changed = true; + } + [ImmerPathSegment::Key(key)] if key == "latestModel" => { + summary.latest_model_changed = true; + } + [ImmerPathSegment::Key(key)] if key == "latestReasoningEffort" => { + summary.latest_reasoning_effort_changed = true; + } + [ImmerPathSegment::Key(key)] if key == "updatedAt" => { + summary.updated_at_changed = true; + } + [ + ImmerPathSegment::Key(key), + ImmerPathSegment::Index(turn_index), + ] if key == "turns" => { + if matches!(&patch.op, ImmerOp::Remove) { + return None; + } + affected_turn_indices.insert(*turn_index); + } + [ + ImmerPathSegment::Key(key), + ImmerPathSegment::Index(turn_index), + .., + ] if key == "turns" => { + affected_turn_indices.insert(*turn_index); + } + _ => return None, + } + } + + summary.affected_turn_indices = affected_turn_indices.into_iter().collect(); + Some(summary) +} + +fn incremental_ipc_thread_status( + existing: ThreadSummaryStatus, + active_turn_id: &Option, + pending_approvals: &[PendingApproval], + pending_user_inputs: &[PendingUserInputRequest], +) -> ThreadSummaryStatus { + if active_turn_id.is_some() || !pending_approvals.is_empty() || !pending_user_inputs.is_empty() + { + ThreadSummaryStatus::Active + } else { + match existing { + ThreadSummaryStatus::SystemError => ThreadSummaryStatus::SystemError, + ThreadSummaryStatus::NotLoaded => ThreadSummaryStatus::Idle, + ThreadSummaryStatus::Idle | ThreadSummaryStatus::Active => ThreadSummaryStatus::Idle, + } + } +} + +fn active_turn_id_from_ipc_conversation_state( + conversation_state: &serde_json::Value, +) -> Option { + let turns = conversation_state.get("turns")?.as_array()?; + turns + .iter() + .enumerate() + .rev() + .find_map(|(turn_index, turn)| { + (turn.get("status").and_then(serde_json::Value::as_str) == Some("inProgress")).then( + || { + turn.get("turnId") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("ipc-turn-{turn_index}")) + }, + ) + }) +} + +fn updated_at_from_ipc_conversation_state(conversation_state: &serde_json::Value) -> Option { + match conversation_state.get("updatedAt") { + Some(serde_json::Value::Number(number)) => number.as_i64(), + Some(serde_json::Value::String(text)) => text.parse().ok(), + _ => None, + } +} + +fn project_incremental_turn( + conversation_state: &serde_json::Value, + turn_index: usize, +) -> Result, String> { + let Some(raw_turn) = conversation_state + .get("turns") + .and_then(serde_json::Value::as_array) + .and_then(|turns| turns.get(turn_index)) + else { + return Ok(None); + }; + + let turn = project_conversation_turn(raw_turn, turn_index) + .map_err(|error| format!("project turn {turn_index}: {error}"))?; + let items = turn + .items + .iter() + .filter_map(|item| { + crate::conversation::hydrate_thread_item( + item, + Some(&turn.id), + Some(turn_index), + &Default::default(), + ) + }) + .collect(); + + Ok(Some(IncrementalProjectedTurn { turn_index, items })) +} + +fn replace_items_for_turn( + thread: &mut ThreadSnapshot, + turn_index: usize, + items: Vec, +) { + let insertion_index = thread + .items + .iter() + .position(|item| item.source_turn_index == Some(turn_index)) + .unwrap_or_else(|| { + thread + .items + .iter() + .position(|item| { + item.source_turn_index + .is_some_and(|index| index > turn_index) + }) + .unwrap_or(thread.items.len()) + }); + + thread + .items + .retain(|item| item.source_turn_index != Some(turn_index)); + + let mut insertion_index = insertion_index.min(thread.items.len()); + for item in items { + thread.items.insert(insertion_index, item); + insertion_index += 1; + } +} + +fn appended_text_delta(existing: &str, projected: &str) -> Option { + projected + .starts_with(existing) + .then(|| projected[existing.len()..].to_string()) +} + +fn appended_optional_text_delta( + existing: &Option, + projected: &Option, +) -> Option { + match (existing.as_deref(), projected.as_deref()) { + (None, None) => Some(String::new()), + (None, Some(projected)) => Some(projected.to_string()), + (Some(existing), Some(projected)) => appended_text_delta(existing, projected), + (Some(_), None) => None, + } +} + +fn appended_reasoning_delta(existing: &[String], projected: &[String]) -> Option { + match (existing, projected) { + ([], []) => Some(String::new()), + ([], [first]) => Some(first.clone()), + ([..], [..]) if existing == projected => Some(String::new()), + ([existing_last], [projected_last]) => appended_text_delta(existing_last, projected_last), + _ if existing.len() == projected.len() && !existing.is_empty() => { + let prefix_len = existing.len() - 1; + if existing[..prefix_len] != projected[..prefix_len] { + return None; + } + appended_text_delta( + existing[prefix_len].as_str(), + projected[prefix_len].as_str(), + ) + } + _ => None, + } +} + +fn diff_incremental_projected_item( + existing: &crate::conversation::ConversationItem, + projected: &crate::conversation::ConversationItem, +) -> Option> { + use crate::conversation::ConversationItemContent; + + if existing.id != projected.id + || existing.source_turn_id != projected.source_turn_id + || existing.source_turn_index != projected.source_turn_index + || existing.timestamp != projected.timestamp + || existing.is_from_user_turn_boundary != projected.is_from_user_turn_boundary + { + return None; + } + + match (&existing.content, &projected.content) { + ( + ConversationItemContent::Assistant(existing_data), + ConversationItemContent::Assistant(projected_data), + ) => { + if existing_data.agent_nickname != projected_data.agent_nickname + || existing_data.agent_role != projected_data.agent_role + || existing_data.phase != projected_data.phase + { + return None; + } + let delta = + appended_text_delta(existing_data.text.as_str(), projected_data.text.as_str())?; + Some(if delta.is_empty() { + Vec::new() + } else { + vec![IncrementalThreadEvent::Streaming( + IncrementalStreamingDelta { + item_id: existing.id.clone(), + kind: ThreadStreamingDeltaKind::AssistantText, + text: delta, + }, + )] + }) + } + ( + ConversationItemContent::Reasoning(existing_data), + ConversationItemContent::Reasoning(projected_data), + ) => { + if existing_data.summary != projected_data.summary { + return None; + } + let delta = appended_reasoning_delta(&existing_data.content, &projected_data.content)?; + Some(if delta.is_empty() { + Vec::new() + } else { + vec![IncrementalThreadEvent::Streaming( + IncrementalStreamingDelta { + item_id: existing.id.clone(), + kind: ThreadStreamingDeltaKind::ReasoningText, + text: delta, + }, + )] + }) + } + ( + ConversationItemContent::ProposedPlan(existing_data), + ConversationItemContent::ProposedPlan(projected_data), + ) => { + let delta = appended_text_delta( + existing_data.content.as_str(), + projected_data.content.as_str(), + )?; + Some(if delta.is_empty() { + Vec::new() + } else { + vec![IncrementalThreadEvent::Streaming( + IncrementalStreamingDelta { + item_id: existing.id.clone(), + kind: ThreadStreamingDeltaKind::PlanText, + text: delta, + }, + )] + }) + } + ( + ConversationItemContent::CommandExecution(existing_data), + ConversationItemContent::CommandExecution(projected_data), + ) => { + if existing_data.command != projected_data.command + || existing_data.cwd != projected_data.cwd + || existing_data.actions != projected_data.actions + { + return None; + } + let delta = + appended_optional_text_delta(&existing_data.output, &projected_data.output)?; + let mut events = Vec::new(); + if !delta.is_empty() { + events.push(IncrementalThreadEvent::Streaming( + IncrementalStreamingDelta { + item_id: existing.id.clone(), + kind: ThreadStreamingDeltaKind::CommandOutput, + text: delta, + }, + )); + } + if existing_data.status != projected_data.status + || existing_data.exit_code != projected_data.exit_code + || existing_data.duration_ms != projected_data.duration_ms + || existing_data.process_id != projected_data.process_id + { + events.push(IncrementalThreadEvent::CommandExecutionUpdated( + IncrementalCommandExecutionUpdate { + item_id: existing.id.clone(), + status: AppOperationStatus::from_raw(projected_data.status.as_str()), + exit_code: projected_data.exit_code, + duration_ms: projected_data.duration_ms, + process_id: projected_data.process_id.clone(), + }, + )); + } + Some(events) + } + ( + ConversationItemContent::McpToolCall(existing_data), + ConversationItemContent::McpToolCall(projected_data), + ) => { + if existing_data.server != projected_data.server + || existing_data.tool != projected_data.tool + || existing_data.status != projected_data.status + || existing_data.duration_ms != projected_data.duration_ms + || existing_data.arguments_json != projected_data.arguments_json + || existing_data.content_summary != projected_data.content_summary + || existing_data.structured_content_json != projected_data.structured_content_json + || existing_data.raw_output_json != projected_data.raw_output_json + || existing_data.error_message != projected_data.error_message + { + return None; + } + if !projected_data + .progress_messages + .starts_with(&existing_data.progress_messages) + { + return None; + } + + let appended = + &projected_data.progress_messages[existing_data.progress_messages.len()..]; + if appended.iter().any(|message| message.trim().is_empty()) { + return None; + } + + Some( + appended + .iter() + .map(|message| { + IncrementalThreadEvent::Streaming(IncrementalStreamingDelta { + item_id: existing.id.clone(), + kind: ThreadStreamingDeltaKind::McpProgress, + text: message.clone(), + }) + }) + .collect(), + ) + } + _ if existing.content == projected.content => Some(Vec::new()), + _ => None, + } +} + +fn diff_incremental_projected_turn( + existing_thread: &ThreadSnapshot, + projected_turn: IncrementalProjectedTurn, +) -> IncrementalTurnMutation { + let existing_items = existing_thread + .items + .iter() + .filter(|item| item.source_turn_index == Some(projected_turn.turn_index)) + .collect::>(); + + if existing_items.len() > projected_turn.items.len() { + return IncrementalTurnMutation::Replace(projected_turn); + } + + let mut events = Vec::new(); + for (existing_item, projected_item) in existing_items.iter().zip(projected_turn.items.iter()) { + let Some(mut item_events) = diff_incremental_projected_item(existing_item, projected_item) + else { + return IncrementalTurnMutation::Replace(projected_turn); + }; + events.append(&mut item_events); + } + + if projected_turn.items.len() > existing_items.len() { + for projected_item in projected_turn.items.iter().skip(existing_items.len()) { + events.push(IncrementalThreadEvent::ItemUpsert(projected_item.clone())); + } + } + + if events.is_empty() { + IncrementalTurnMutation::Unchanged + } else { + IncrementalTurnMutation::Patched { + projected_turn, + events, + } + } +} + +fn apply_incremental_thread_event( + thread: &mut ThreadSnapshot, + event: &IncrementalThreadEvent, +) -> bool { + use crate::conversation::ConversationItemContent; + + match event { + IncrementalThreadEvent::Streaming(delta) => { + let Some(item) = thread + .items + .iter_mut() + .find(|item| item.id == delta.item_id) + else { + return false; + }; + + match (&mut item.content, &delta.kind) { + ( + ConversationItemContent::Assistant(data), + ThreadStreamingDeltaKind::AssistantText, + ) => { + data.text.push_str(delta.text.as_str()); + true + } + ( + ConversationItemContent::Reasoning(data), + ThreadStreamingDeltaKind::ReasoningText, + ) => { + if let Some(last) = data.content.last_mut() { + last.push_str(delta.text.as_str()); + } else { + data.content.push(delta.text.clone()); + } + true + } + ( + ConversationItemContent::ProposedPlan(data), + ThreadStreamingDeltaKind::PlanText, + ) => { + data.content.push_str(delta.text.as_str()); + true + } + ( + ConversationItemContent::CommandExecution(data), + ThreadStreamingDeltaKind::CommandOutput, + ) => { + data.output + .get_or_insert_with(String::new) + .push_str(delta.text.as_str()); + true + } + ( + ConversationItemContent::McpToolCall(data), + ThreadStreamingDeltaKind::McpProgress, + ) => { + if !delta.text.trim().is_empty() { + data.progress_messages.push(delta.text.clone()); + } + true + } + _ => false, + } + } + IncrementalThreadEvent::CommandExecutionUpdated(update) => { + let Some(item) = thread + .items + .iter_mut() + .find(|item| item.id == update.item_id) + else { + return false; + }; + let ConversationItemContent::CommandExecution(data) = &mut item.content else { + return false; + }; + data.status = match update.status { + AppOperationStatus::Pending => "pending".to_string(), + AppOperationStatus::InProgress => "inProgress".to_string(), + AppOperationStatus::Completed => "completed".to_string(), + AppOperationStatus::Failed => "failed".to_string(), + AppOperationStatus::Declined => "declined".to_string(), + AppOperationStatus::Unknown => data.status.clone(), + }; + data.exit_code = update.exit_code; + data.duration_ms = update.duration_ms; + data.process_id = update.process_id.clone(); + true + } + IncrementalThreadEvent::ItemUpsert(item) => { + if let Some(existing) = thread + .items + .iter_mut() + .find(|existing| existing.id == item.id) + { + *existing = item.clone(); + } else { + thread.items.push(item.clone()); + } + true + } + } +} + +fn try_apply_incremental_ipc_patch_burst( + app_store: &AppStoreReducer, + server_id: &str, + thread_id: &str, + conversation_state: &serde_json::Value, + summary: &IncrementalIpcPatchSummary, +) -> Result { + let key = ThreadKey { + server_id: server_id.to_string(), + thread_id: thread_id.to_string(), + }; + let snapshot = app_store.snapshot(); + let Some(existing_thread) = snapshot.threads.get(&key).cloned() else { + return Ok(false); + }; + + let mut pending_approvals = snapshot + .pending_approvals + .iter() + .filter(|approval| { + approval.server_id == server_id && approval.thread_id.as_deref() == Some(thread_id) + }) + .cloned() + .collect::>(); + let mut pending_user_inputs = snapshot + .pending_user_inputs + .iter() + .filter(|request| request.server_id == server_id && request.thread_id == thread_id) + .cloned() + .collect::>(); + + if summary.requests_changed { + let projected = project_conversation_request_state(conversation_state) + .map_err(|error| format!("project request state: {error}"))?; + pending_approvals = projected + .pending_approvals + .into_iter() + .map(|approval| pending_approval_from_ipc_projection(server_id, approval)) + .collect(); + pending_user_inputs = projected + .pending_user_inputs + .into_iter() + .map(|request| pending_user_input_from_ipc_projection(server_id, request)) + .collect(); + } + + let mut projected_turns = Vec::with_capacity(summary.affected_turn_indices.len()); + for &turn_index in &summary.affected_turn_indices { + if let Some(projected_turn) = project_incremental_turn(conversation_state, turn_index)? { + projected_turns.push(projected_turn); + } + } + + let turn_mutations = projected_turns + .into_iter() + .map(|projected_turn| diff_incremental_projected_turn(&existing_thread, projected_turn)) + .collect::>(); + + let active_turn_id = if summary.affected_turn_indices.is_empty() && !summary.requests_changed { + existing_thread.active_turn_id.clone() + } else { + active_turn_id_from_ipc_conversation_state(conversation_state) + }; + let updated_at = if summary.updated_at_changed { + updated_at_from_ipc_conversation_state(conversation_state) + } else { + existing_thread.info.updated_at + }; + let latest_model = if summary.latest_model_changed { + conversation_state + .get("latestModel") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + } else { + existing_thread.model.clone() + }; + let latest_reasoning_effort = if summary.latest_reasoning_effort_changed { + conversation_state + .get("latestReasoningEffort") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + } else { + existing_thread.reasoning_effort.clone() + }; + let thread_status = incremental_ipc_thread_status( + existing_thread.info.status.clone(), + &active_turn_id, + &pending_approvals, + &pending_user_inputs, + ); + let meta_changed = existing_thread.active_turn_id != active_turn_id + || existing_thread.info.status != thread_status + || existing_thread.info.updated_at != updated_at + || existing_thread.model != latest_model + || existing_thread.reasoning_effort != latest_reasoning_effort; + + let mutation = IncrementalThreadMutation { + turn_mutations, + active_turn_id, + updated_at, + latest_model, + latest_reasoning_effort, + thread_status, + }; + + let Some(mutation_result) = app_store.mutate_thread_with_result(&key, |thread| { + let mut emitted_deltas = Vec::new(); + let mut emitted_command_updates = Vec::new(); + let mut emitted_item_upserts = Vec::new(); + let mut requires_thread_upsert = false; + + for turn_mutation in &mutation.turn_mutations { + match turn_mutation { + IncrementalTurnMutation::Unchanged => {} + IncrementalTurnMutation::Replace(projected_turn) => { + replace_items_for_turn( + thread, + projected_turn.turn_index, + projected_turn.items.clone(), + ); + requires_thread_upsert = true; + } + IncrementalTurnMutation::Patched { + projected_turn, + events, + } => { + let mut applied = true; + for event in events { + if !apply_incremental_thread_event(thread, event) { + applied = false; + break; + } + } + + if applied { + for event in events { + match event { + IncrementalThreadEvent::Streaming(delta) => { + emitted_deltas.push(delta.clone()); + } + IncrementalThreadEvent::CommandExecutionUpdated(update) => { + emitted_command_updates.push(update.clone()); + } + IncrementalThreadEvent::ItemUpsert(item) => { + emitted_item_upserts.push(item.clone()); + } + } + } + } else { + replace_items_for_turn( + thread, + projected_turn.turn_index, + projected_turn.items.clone(), + ); + requires_thread_upsert = true; + } + } + } + } + + thread.active_turn_id = mutation.active_turn_id.clone(); + thread.info.status = mutation.thread_status.clone(); + thread.info.updated_at = mutation.updated_at; + thread.model = mutation.latest_model.clone(); + thread.reasoning_effort = mutation.latest_reasoning_effort.clone(); + + IncrementalMutationResult { + requires_thread_upsert, + emitted_deltas, + emitted_command_updates, + emitted_item_upserts, + emit_thread_state_update: meta_changed, + } + }) else { + return Ok(false); + }; + + if mutation_result.requires_thread_upsert { + app_store.emit_thread_upsert(&key); + } else { + if mutation_result.emit_thread_state_update { + app_store.emit_thread_state_update(&key); + } + for item in mutation_result.emitted_item_upserts { + app_store.emit_thread_item_upsert(&key, &item); + } + for update in mutation_result.emitted_command_updates { + app_store.emit_thread_command_execution_updated( + &key, + &update.item_id, + update.status, + update.exit_code, + update.duration_ms, + update.process_id, + ); + } + for delta in mutation_result.emitted_deltas { + app_store.emit_thread_streaming_delta(&key, &delta.item_id, delta.kind, &delta.text); + } + } + + if summary.requests_changed { + sync_ipc_thread_requests( + app_store, + server_id, + thread_id, + pending_approvals, + pending_user_inputs, + ); + } + + Ok(true) +} + #[derive(Debug)] enum StreamHandleError { - VersionGap, NoCachedState, DeserializeFailed(String), PatchFailed(String), } fn handle_stream_state_change( - cache: &mut HashMap, + cache: &mut HashMap, app_store: &AppStoreReducer, server_id: &str, params: &ThreadStreamStateChangedParams, @@ -2507,16 +3329,47 @@ fn handle_stream_state_change( apply_stream_change_to_conversation_state(&mut cached_state, params).map_err(|error| { match error { ConversationStreamApplyError::NoCachedState => StreamHandleError::NoCachedState, - ConversationStreamApplyError::VersionGap { .. } => StreamHandleError::VersionGap, ConversationStreamApplyError::PatchFailed(error) => { StreamHandleError::PatchFailed(error.to_string()) } } })?; - let (_, conversation_state) = cached_state + let conversation_state = cached_state .as_ref() .expect("cached state should exist after successful stream apply"); + + if let StreamChange::Patches { patches } = ¶ms.change + && let Some(summary) = summarize_incremental_ipc_patches(patches) + { + match try_apply_incremental_ipc_patch_burst( + app_store, + server_id, + ¶ms.conversation_id, + conversation_state, + &summary, + ) { + Ok(true) => { + trace!( + "IPC: applied incremental patch burst for thread={} turns={:?} requests_changed={}", + params.conversation_id, summary.affected_turn_indices, summary.requests_changed + ); + cache.insert( + params.conversation_id.clone(), + cached_state.expect("cached state should exist after successful stream apply"), + ); + return Ok(()); + } + Ok(false) => {} + Err(error) => { + debug!( + "IPC: incremental patch burst fallback for thread={}: {}", + params.conversation_id, error + ); + } + } + } + let ThreadProjection { mut snapshot, pending_approvals, @@ -2730,9 +3583,24 @@ fn approval_response_json( mod mobile_client_tests { use super::*; use crate::conversation::ConversationItemContent; + use crate::store::AppUpdate; + use crate::store::updates::ThreadStreamingDeltaKind; use crate::types::ThreadSummaryStatus; use serde_json::json; use std::path::PathBuf; + use tokio::sync::broadcast::error::TryRecvError; + + fn drain_app_updates(rx: &mut tokio::sync::broadcast::Receiver) -> Vec { + let mut updates = Vec::new(); + loop { + match rx.try_recv() { + Ok(update) => updates.push(update), + Err(TryRecvError::Empty) | Err(TryRecvError::Closed) => break, + Err(TryRecvError::Lagged(_)) => continue, + } + } + updates + } fn make_thread_info(id: &str) -> ThreadInfo { ThreadInfo { @@ -2782,6 +3650,10 @@ mod mobile_client_tests { reasoning_effort: Some("high".to_string()), items: Vec::new(), local_overlay_items: Vec::new(), + queued_follow_ups: vec![QueuedFollowUpPreview { + id: "queued-1".to_string(), + text: "follow-up".to_string(), + }], active_turn_id: Some("turn-1".to_string()), context_tokens_used: Some(12_345), model_context_window: Some(200_000), @@ -2798,6 +3670,7 @@ mod mobile_client_tests { assert_eq!(target.model.as_deref(), Some("gpt-5")); assert_eq!(target.reasoning_effort.as_deref(), Some("high")); + assert_eq!(target.queued_follow_ups, source.queued_follow_ups); assert_eq!(target.active_turn_id, None); assert_eq!(target.context_tokens_used, Some(12_345)); assert_eq!(target.model_context_window, Some(200_000)); @@ -2820,6 +3693,7 @@ mod mobile_client_tests { #[test] fn handle_stream_state_change_streams_patches_and_updates_ipc_request_state() { let app_store = AppStoreReducer::new(); + let mut updates = app_store.subscribe(); let mut cache = HashMap::new(); let thread_id = "thread-1"; let server_id = "srv"; @@ -2830,7 +3704,7 @@ mod mobile_client_tests { let snapshot_params = ThreadStreamStateChangedParams { conversation_id: thread_id.to_string(), - version: 1, + version: 5, change: StreamChange::Snapshot { conversation_state: json!({ "latestModel": "gpt-5.4", @@ -2867,6 +3741,7 @@ mod mobile_client_tests { }; handle_stream_state_change(&mut cache, &app_store, server_id, &snapshot_params).unwrap(); + drain_app_updates(&mut updates); let snapshot = app_store.snapshot(); let thread = snapshot.threads.get(&key).unwrap(); @@ -2884,7 +3759,7 @@ mod mobile_client_tests { let text_patch = ThreadStreamStateChangedParams { conversation_id: thread_id.to_string(), - version: 2, + version: 5, change: StreamChange::Patches { patches: vec![ codex_ipc::ImmerPatch { @@ -2909,6 +3784,21 @@ mod mobile_client_tests { handle_stream_state_change(&mut cache, &app_store, server_id, &text_patch).unwrap(); + let emitted = drain_app_updates(&mut updates); + assert!(emitted.iter().any(|update| matches!( + update, + AppUpdate::ThreadStreamingDelta { + key: emitted_key, + item_id, + kind: ThreadStreamingDeltaKind::AssistantText, + text, + } if emitted_key == &key && item_id == "assistant-1" && text == "lo" + ))); + assert!(!emitted.iter().any(|update| matches!( + update, + AppUpdate::ThreadUpserted { thread, .. } if thread.key == key + ))); + let snapshot = app_store.snapshot(); let thread = snapshot.threads.get(&key).unwrap(); assert_eq!(thread.active_turn_id.as_deref(), Some("turn-1")); @@ -2923,7 +3813,7 @@ mod mobile_client_tests { let completion_patch = ThreadStreamStateChangedParams { conversation_id: thread_id.to_string(), - version: 3, + version: 5, change: StreamChange::Patches { patches: vec![codex_ipc::ImmerPatch { op: codex_ipc::ImmerOp::Replace, @@ -2939,6 +3829,12 @@ mod mobile_client_tests { handle_stream_state_change(&mut cache, &app_store, server_id, &completion_patch).unwrap(); + let completion_updates = drain_app_updates(&mut updates); + assert!(completion_updates.iter().any(|update| matches!( + update, + AppUpdate::ThreadStateUpdated { state, .. } if state.key == key + ))); + let snapshot = app_store.snapshot(); let thread = snapshot.threads.get(&key).unwrap(); assert_eq!(thread.active_turn_id, None); @@ -2994,12 +3890,12 @@ mod mobile_client_tests { }; let mut cache = HashMap::from([( thread_id.to_string(), - (1, seed_conversation_state_from_thread(&thread)), + seed_conversation_state_from_thread(&thread), )]); let text_patch = ThreadStreamStateChangedParams { conversation_id: thread_id.to_string(), - version: 2, + version: 5, change: StreamChange::Patches { patches: vec![codex_ipc::ImmerPatch { op: codex_ipc::ImmerOp::Replace, @@ -3028,4 +3924,189 @@ mod mobile_client_tests { Some("hello") ); } + + #[test] + fn handle_stream_state_change_applies_same_protocol_version_patch_bursts() { + let app_store = AppStoreReducer::new(); + let mut cache = HashMap::new(); + let thread_id = "thread-1"; + let server_id = "srv"; + let key = ThreadKey { + server_id: server_id.to_string(), + thread_id: thread_id.to_string(), + }; + + let snapshot_params = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Snapshot { + conversation_state: json!({ + "turns": [ + { + "turnId": "turn-1", + "status": "inProgress", + "params": { + "input": [ + { "type": "text", "text": "hello", "textElements": [] } + ] + }, + "items": [ + { "id": "assistant-1", "type": "agentMessage", "text": "hel" } + ] + } + ], + "requests": [] + }), + }, + }; + handle_stream_state_change(&mut cache, &app_store, server_id, &snapshot_params).unwrap(); + + let first_text_patch = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![codex_ipc::ImmerPatch { + op: codex_ipc::ImmerOp::Replace, + path: vec![ + codex_ipc::ImmerPathSegment::Key("turns".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("items".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("text".to_string()), + ], + value: Some(json!("hell")), + }], + }, + }; + let second_text_patch = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![codex_ipc::ImmerPatch { + op: codex_ipc::ImmerOp::Replace, + path: vec![ + codex_ipc::ImmerPathSegment::Key("turns".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("items".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("text".to_string()), + ], + value: Some(json!("hello")), + }], + }, + }; + handle_stream_state_change(&mut cache, &app_store, server_id, &first_text_patch).unwrap(); + handle_stream_state_change(&mut cache, &app_store, server_id, &second_text_patch).unwrap(); + + let snapshot = app_store.snapshot(); + let thread = snapshot.threads.get(&key).unwrap(); + assert_eq!( + thread.items.iter().find_map(|item| match &item.content { + ConversationItemContent::Assistant(data) => Some(data.text.as_str()), + _ => None, + }), + Some("hello") + ); + } + + #[test] + fn handle_stream_state_change_marks_shell_turn_active_before_real_turn_id_arrives() { + let app_store = AppStoreReducer::new(); + let mut cache = HashMap::new(); + let thread_id = "thread-1"; + let server_id = "srv"; + let key = ThreadKey { + server_id: server_id.to_string(), + thread_id: thread_id.to_string(), + }; + + let snapshot_params = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Snapshot { + conversation_state: json!({ + "turns": [], + "requests": [] + }), + }, + }; + handle_stream_state_change(&mut cache, &app_store, server_id, &snapshot_params).unwrap(); + + let add_shell_turn_patch = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![codex_ipc::ImmerPatch { + op: codex_ipc::ImmerOp::Add, + path: vec![ + codex_ipc::ImmerPathSegment::Key("turns".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + ], + value: Some(json!({ + "status": "inProgress", + "items": [], + "params": { "input": [] }, + "interruptedCommandExecutionItemIds": [] + })), + }], + }, + }; + handle_stream_state_change(&mut cache, &app_store, server_id, &add_shell_turn_patch) + .unwrap(); + + let snapshot = app_store.snapshot(); + let thread = snapshot.threads.get(&key).unwrap(); + assert_eq!(thread.active_turn_id.as_deref(), Some("ipc-turn-0")); + assert_eq!(thread.info.status, ThreadSummaryStatus::Active); + + let finalize_turn_identity_patch = ThreadStreamStateChangedParams { + conversation_id: thread_id.to_string(), + version: 5, + change: StreamChange::Patches { + patches: vec![ + codex_ipc::ImmerPatch { + op: codex_ipc::ImmerOp::Replace, + path: vec![ + codex_ipc::ImmerPathSegment::Key("turns".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("turnId".to_string()), + ], + value: Some(json!("turn-1")), + }, + codex_ipc::ImmerPatch { + op: codex_ipc::ImmerOp::Add, + path: vec![ + codex_ipc::ImmerPathSegment::Key("turns".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + codex_ipc::ImmerPathSegment::Key("items".to_string()), + codex_ipc::ImmerPathSegment::Index(0), + ], + value: Some(json!({ + "id": "assistant-1", + "type": "agentMessage", + "text": "h" + })), + }, + ], + }, + }; + handle_stream_state_change( + &mut cache, + &app_store, + server_id, + &finalize_turn_identity_patch, + ) + .unwrap(); + + let snapshot = app_store.snapshot(); + let thread = snapshot.threads.get(&key).unwrap(); + assert_eq!(thread.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!( + thread.items.iter().find_map(|item| match &item.content { + ConversationItemContent::Assistant(data) => Some(data.text.as_str()), + _ => None, + }), + Some("h") + ); + } } diff --git a/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs b/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs index 99f88496..552e7f9d 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/boundary.rs @@ -3,15 +3,16 @@ use std::hash::{Hash, Hasher}; use crate::conversation_uniffi::HydratedConversationItem; use crate::types::{PendingApproval, PendingUserInputRequest, ThreadInfo, ThreadKey}; use crate::uniffi_shared::{ - AppSubagentStatus, AppVoiceHandoffRequest, AppVoiceSessionPhase, AppVoiceTranscriptEntry, - AppVoiceTranscriptUpdate, + AppOperationStatus, AppSubagentStatus, AppVoiceHandoffRequest, AppVoiceSessionPhase, + AppVoiceTranscriptEntry, AppVoiceTranscriptUpdate, }; use super::snapshot::{ AppSnapshot, ServerConnectionProgressSnapshot, ServerConnectionStepKind, - ServerConnectionStepSnapshot, ServerConnectionStepState, ServerHealthSnapshot, + ServerConnectionStepSnapshot, ServerConnectionStepState, ServerHealthSnapshot, ServerSnapshot, + ThreadSnapshot, }; -use super::updates::AppUpdate; +use super::updates::{AppUpdate, ThreadStreamingDeltaKind}; #[derive(Debug, Clone, uniffi::Enum)] pub enum AppServerConnectionStepKind { @@ -72,6 +73,12 @@ pub enum AppServerHealth { Unknown, } +#[derive(Debug, Clone, uniffi::Record)] +pub struct AppQueuedFollowUpPreview { + pub id: String, + pub text: String, +} + #[derive(Debug, Clone, uniffi::Record)] pub struct AppThreadSnapshot { pub key: ThreadKey, @@ -79,6 +86,21 @@ pub struct AppThreadSnapshot { pub model: Option, pub reasoning_effort: Option, pub hydrated_conversation_items: Vec, + pub queued_follow_ups: Vec, + pub active_turn_id: Option, + pub context_tokens_used: Option, + pub model_context_window: Option, + pub rate_limits_json: Option, + pub realtime_session_id: Option, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct AppThreadStateRecord { + pub key: ThreadKey, + pub info: ThreadInfo, + pub model: Option, + pub reasoning_effort: Option, + pub queued_follow_ups: Vec, pub active_turn_id: Option, pub context_tokens_used: Option, pub model_context_window: Option, @@ -90,23 +112,71 @@ impl TryFrom for AppThreadSnapshot { type Error = String; fn try_from(thread: super::snapshot::ThreadSnapshot) -> Result { + (&thread).try_into() + } +} + +impl TryFrom<&super::snapshot::ThreadSnapshot> for AppThreadSnapshot { + type Error = String; + + fn try_from(thread: &super::snapshot::ThreadSnapshot) -> Result { let hydrated_conversation_items = - merged_hydrated_items(thread.items, thread.local_overlay_items); + merged_hydrated_items(thread.items.clone(), thread.local_overlay_items.clone()); Ok(Self { - key: thread.key, - info: thread.info, - model: thread.model, - reasoning_effort: thread.reasoning_effort, + key: thread.key.clone(), + info: thread.info.clone(), + model: thread.model.clone(), + reasoning_effort: thread.reasoning_effort.clone(), hydrated_conversation_items, - active_turn_id: thread.active_turn_id, + queued_follow_ups: thread + .queued_follow_ups + .iter() + .map(|preview| AppQueuedFollowUpPreview { + id: preview.id.clone(), + text: preview.text.clone(), + }) + .collect(), + active_turn_id: thread.active_turn_id.clone(), + context_tokens_used: thread.context_tokens_used, + model_context_window: thread.model_context_window, + rate_limits_json: thread + .rate_limits + .clone() + .map(|limits| serde_json::to_string(&limits)) + .transpose() + .map_err(|error| format!("serialize rate limits: {error}"))?, + realtime_session_id: thread.realtime_session_id.clone(), + }) + } +} + +impl TryFrom<&super::snapshot::ThreadSnapshot> for AppThreadStateRecord { + type Error = String; + + fn try_from(thread: &super::snapshot::ThreadSnapshot) -> Result { + Ok(Self { + key: thread.key.clone(), + info: thread.info.clone(), + model: thread.model.clone(), + reasoning_effort: thread.reasoning_effort.clone(), + queued_follow_ups: thread + .queued_follow_ups + .iter() + .map(|preview| AppQueuedFollowUpPreview { + id: preview.id.clone(), + text: preview.text.clone(), + }) + .collect(), + active_turn_id: thread.active_turn_id.clone(), context_tokens_used: thread.context_tokens_used, model_context_window: thread.model_context_window, rate_limits_json: thread .rate_limits + .clone() .map(|limits| serde_json::to_string(&limits)) .transpose() .map_err(|error| format!("serialize rate limits: {error}"))?, - realtime_session_id: thread.realtime_session_id, + realtime_session_id: thread.realtime_session_id.clone(), }) } } @@ -191,87 +261,7 @@ impl TryFrom for AppSnapshotRecord { type Error = String; fn try_from(snapshot: AppSnapshot) -> Result { - let mut session_summaries = snapshot - .threads - .values() - .map(|thread| { - let server = snapshot.servers.get(&thread.key.server_id); - let preview = thread.info.preview.clone().unwrap_or_default(); - let title = { - let explicit_title = thread.info.title.clone().unwrap_or_default(); - let trimmed_title = explicit_title.trim(); - if !trimmed_title.is_empty() { - trimmed_title.to_string() - } else { - let trimmed_preview = preview.trim(); - if !trimmed_preview.is_empty() { - trimmed_preview.to_string() - } else { - "Untitled session".to_string() - } - } - }; - let parent_thread_id = thread.info.parent_thread_id.clone().and_then(|value| { - let trimmed = value.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }); - let has_agent_label = thread - .info - .agent_nickname - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || thread - .info - .agent_role - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - let is_fork = parent_thread_id.is_some(); - - AppSessionSummary { - key: thread.key.clone(), - server_display_name: server - .map(|server| server.display_name.clone()) - .unwrap_or_else(|| thread.key.server_id.clone()), - server_host: server - .map(|server| server.host.clone()) - .unwrap_or_else(|| thread.key.server_id.clone()), - title, - preview, - cwd: thread.info.cwd.clone().unwrap_or_default(), - model: thread - .info - .model - .clone() - .or_else(|| thread.model.clone()) - .unwrap_or_default(), - model_provider: thread.info.model_provider.clone().unwrap_or_default(), - parent_thread_id, - agent_nickname: thread.info.agent_nickname.clone(), - agent_role: thread.info.agent_role.clone(), - agent_display_label: agent_display_label( - thread.info.agent_nickname.as_deref(), - thread.info.agent_role.as_deref(), - None, - ), - agent_status: thread - .info - .agent_status - .as_deref() - .map(AppSubagentStatus::from_raw) - .unwrap_or(AppSubagentStatus::Unknown), - updated_at: thread.info.updated_at, - has_active_turn: thread.active_turn_id.is_some(), - is_subagent: is_fork && has_agent_label, - is_fork, - } - }) - .collect::>(); - session_summaries.sort_by(|lhs, rhs| { - rhs.updated_at - .cmp(&lhs.updated_at) - .then_with(|| lhs.key.server_id.cmp(&rhs.key.server_id)) - .then_with(|| lhs.key.thread_id.cmp(&rhs.key.thread_id)) - }); + let session_summaries = session_summaries_from_snapshot(&snapshot); let agent_directory_version = agent_directory_version(&session_summaries); let mut servers = snapshot @@ -296,7 +286,7 @@ impl TryFrom for AppSnapshotRecord { let mut threads = snapshot .threads - .into_values() + .values() .map(AppThreadSnapshot::try_from) .collect::, String>>()?; threads.sort_by(|lhs, rhs| lhs.key.thread_id.cmp(&rhs.key.thread_id)); @@ -321,6 +311,139 @@ impl TryFrom for AppSnapshotRecord { } } +pub(crate) fn session_summaries_from_snapshot(snapshot: &AppSnapshot) -> Vec { + let mut session_summaries = snapshot + .threads + .values() + .map(|thread| app_session_summary(thread, snapshot.servers.get(&thread.key.server_id))) + .collect::>(); + sort_session_summaries(&mut session_summaries); + session_summaries +} + +pub(crate) fn app_session_summary( + thread: &ThreadSnapshot, + server: Option<&ServerSnapshot>, +) -> AppSessionSummary { + let preview = thread.info.preview.clone().unwrap_or_default(); + let title = { + let explicit_title = thread.info.title.clone().unwrap_or_default(); + let trimmed_title = explicit_title.trim(); + if !trimmed_title.is_empty() { + trimmed_title.to_string() + } else { + let trimmed_preview = preview.trim(); + if !trimmed_preview.is_empty() { + trimmed_preview.to_string() + } else { + "Untitled session".to_string() + } + } + }; + let parent_thread_id = thread.info.parent_thread_id.clone().and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }); + let has_agent_label = thread + .info + .agent_nickname + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || thread + .info + .agent_role + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let is_fork = parent_thread_id.is_some(); + + AppSessionSummary { + key: thread.key.clone(), + server_display_name: server + .map(|server| server.display_name.clone()) + .unwrap_or_else(|| thread.key.server_id.clone()), + server_host: server + .map(|server| server.host.clone()) + .unwrap_or_else(|| thread.key.server_id.clone()), + title, + preview, + cwd: thread.info.cwd.clone().unwrap_or_default(), + model: thread + .info + .model + .clone() + .or_else(|| thread.model.clone()) + .unwrap_or_default(), + model_provider: thread.info.model_provider.clone().unwrap_or_default(), + parent_thread_id, + agent_nickname: thread.info.agent_nickname.clone(), + agent_role: thread.info.agent_role.clone(), + agent_display_label: agent_display_label( + thread.info.agent_nickname.as_deref(), + thread.info.agent_role.as_deref(), + None, + ), + agent_status: thread + .info + .agent_status + .as_deref() + .map(AppSubagentStatus::from_raw) + .unwrap_or(AppSubagentStatus::Unknown), + updated_at: thread.info.updated_at, + has_active_turn: thread.active_turn_id.is_some(), + is_subagent: is_fork && has_agent_label, + is_fork, + } +} + +pub(crate) fn sort_session_summaries(session_summaries: &mut [AppSessionSummary]) { + session_summaries.sort_by(|lhs, rhs| { + rhs.updated_at + .cmp(&lhs.updated_at) + .then_with(|| lhs.key.server_id.cmp(&rhs.key.server_id)) + .then_with(|| lhs.key.thread_id.cmp(&rhs.key.thread_id)) + }); +} + +pub(crate) fn project_thread_update( + snapshot: &AppSnapshot, + key: &ThreadKey, +) -> Result, String> { + let Some(thread) = snapshot.threads.get(key) else { + return Ok(None); + }; + let thread_snapshot = AppThreadSnapshot::try_from(thread)?; + let session_summary = app_session_summary(thread, snapshot.servers.get(&key.server_id)); + let agent_directory_version = + agent_directory_version(&session_summaries_from_snapshot(snapshot)); + Ok(Some(( + thread_snapshot, + session_summary, + agent_directory_version, + ))) +} + +pub(crate) fn project_thread_state_update( + snapshot: &AppSnapshot, + key: &ThreadKey, +) -> Result, String> { + let Some(thread) = snapshot.threads.get(key) else { + return Ok(None); + }; + let thread_state = AppThreadStateRecord::try_from(thread)?; + let session_summary = app_session_summary(thread, snapshot.servers.get(&key.server_id)); + let agent_directory_version = + agent_directory_version(&session_summaries_from_snapshot(snapshot)); + Ok(Some(( + thread_state, + session_summary, + agent_directory_version, + ))) +} + +pub(crate) fn current_agent_directory_version(snapshot: &AppSnapshot) -> u64 { + agent_directory_version(&session_summaries_from_snapshot(snapshot)) +} + impl From for AppServerHealth { fn from(value: ServerHealthSnapshot) -> Self { match value { @@ -417,6 +540,15 @@ fn sanitized_label_field(raw: Option<&str>) -> Option<&str> { }) } +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum AppThreadStreamingDeltaKind { + AssistantText, + ReasoningText, + PlanText, + CommandOutput, + McpProgress, +} + #[derive(Debug, Clone, uniffi::Enum)] pub enum AppStoreUpdateRecord { FullResync, @@ -426,11 +558,37 @@ pub enum AppStoreUpdateRecord { ServerRemoved { server_id: String, }, - ThreadChanged { + ThreadUpserted { + thread: AppThreadSnapshot, + session_summary: AppSessionSummary, + agent_directory_version: u64, + }, + ThreadStateUpdated { + state: AppThreadStateRecord, + session_summary: AppSessionSummary, + agent_directory_version: u64, + }, + ThreadItemUpserted { key: ThreadKey, + item: HydratedConversationItem, + }, + ThreadCommandExecutionUpdated { + key: ThreadKey, + item_id: String, + status: AppOperationStatus, + exit_code: Option, + duration_ms: Option, + process_id: Option, + }, + ThreadStreamingDelta { + key: ThreadKey, + item_id: String, + kind: AppThreadStreamingDeltaKind, + text: String, }, ThreadRemoved { key: ThreadKey, + agent_directory_version: u64, }, ActiveThreadChanged { key: Option, @@ -473,8 +631,58 @@ impl From for AppStoreUpdateRecord { AppUpdate::FullResync => Self::FullResync, AppUpdate::ServerChanged { server_id } => Self::ServerChanged { server_id }, AppUpdate::ServerRemoved { server_id } => Self::ServerRemoved { server_id }, - AppUpdate::ThreadChanged { key } => Self::ThreadChanged { key }, - AppUpdate::ThreadRemoved { key } => Self::ThreadRemoved { key }, + AppUpdate::ThreadUpserted { + thread, + session_summary, + agent_directory_version, + } => Self::ThreadUpserted { + thread, + session_summary, + agent_directory_version, + }, + AppUpdate::ThreadStateUpdated { + state, + session_summary, + agent_directory_version, + } => Self::ThreadStateUpdated { + state, + session_summary, + agent_directory_version, + }, + AppUpdate::ThreadItemUpserted { key, item } => Self::ThreadItemUpserted { key, item }, + AppUpdate::ThreadCommandExecutionUpdated { + key, + item_id, + status, + exit_code, + duration_ms, + process_id, + } => Self::ThreadCommandExecutionUpdated { + key, + item_id, + status, + exit_code, + duration_ms, + process_id, + }, + AppUpdate::ThreadStreamingDelta { + key, + item_id, + kind, + text, + } => Self::ThreadStreamingDelta { + key, + item_id, + kind: kind.into(), + text, + }, + AppUpdate::ThreadRemoved { + key, + agent_directory_version, + } => Self::ThreadRemoved { + key, + agent_directory_version, + }, AppUpdate::ActiveThreadChanged { key } => Self::ActiveThreadChanged { key }, AppUpdate::PendingApprovalsChanged { .. } => Self::PendingApprovalsChanged, AppUpdate::PendingUserInputsChanged { .. } => Self::PendingUserInputsChanged, @@ -501,3 +709,15 @@ impl From for AppStoreUpdateRecord { } } } + +impl From for AppThreadStreamingDeltaKind { + fn from(value: ThreadStreamingDeltaKind) -> Self { + match value { + ThreadStreamingDeltaKind::AssistantText => Self::AssistantText, + ThreadStreamingDeltaKind::ReasoningText => Self::ReasoningText, + ThreadStreamingDeltaKind::PlanText => Self::PlanText, + ThreadStreamingDeltaKind::CommandOutput => Self::CommandOutput, + ThreadStreamingDeltaKind::McpProgress => Self::McpProgress, + } + } +} diff --git a/shared/rust-bridge/codex-mobile-client/src/store/mod.rs b/shared/rust-bridge/codex-mobile-client/src/store/mod.rs index 874d3fc0..88a71ca5 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/mod.rs @@ -9,12 +9,13 @@ mod voice; pub use boundary::{ AppServerConnectionProgress, AppServerConnectionStep, AppServerConnectionStepKind, AppServerConnectionStepState, AppServerHealth, AppServerSnapshot, AppSessionSummary, - AppSnapshotRecord, AppStoreUpdateRecord, AppThreadSnapshot, AppVoiceSessionSnapshot, + AppSnapshotRecord, AppStoreUpdateRecord, AppThreadSnapshot, AppThreadStateRecord, + AppThreadStreamingDeltaKind, AppVoiceSessionSnapshot, }; pub use reducer::AppStoreReducer; pub use snapshot::{ AppSnapshot, ServerConnectionProgressSnapshot, ServerConnectionStepKind, ServerConnectionStepSnapshot, ServerConnectionStepState, ServerHealthSnapshot, ServerSnapshot, - ThreadSnapshot, VoiceSessionSnapshot, + QueuedFollowUpPreview, ThreadSnapshot, VoiceSessionSnapshot, }; -pub use updates::AppUpdate; +pub use updates::{AppUpdate, ThreadStreamingDeltaKind}; diff --git a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs index 88b3a532..56e0ae46 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/reducer.rs @@ -8,6 +8,7 @@ use crate::conversation::{ UserInputResponseOptionData, UserInputResponseQuestionData, make_error_item, make_model_rerouted_item, make_turn_diff_item, }; +use crate::conversation_uniffi::HydratedConversationItem; use crate::session::connection::ServerConfig; use crate::session::events::UiEvent; use crate::types::{ @@ -15,18 +16,21 @@ use crate::types::{ PendingUserInputRequest, ThreadInfo, ThreadKey, ThreadSummaryStatus, generated, }; use crate::uniffi_shared::{ - AppVoiceSessionPhase, AppVoiceTranscriptEntry, AppVoiceTranscriptUpdate, + AppOperationStatus, AppVoiceSessionPhase, AppVoiceTranscriptEntry, AppVoiceTranscriptUpdate, }; use super::actions::{ conversation_item_from_upstream, thread_info_from_upstream, thread_info_from_upstream_status_change, }; +use super::boundary::{ + current_agent_directory_version, project_thread_state_update, project_thread_update, +}; use super::snapshot::{ - AppSnapshot, ServerConnectionProgressSnapshot, ServerHealthSnapshot, ServerSnapshot, - ThreadSnapshot, VoiceSessionSnapshot, + AppSnapshot, QueuedFollowUpPreview, ServerConnectionProgressSnapshot, ServerHealthSnapshot, + ServerSnapshot, ThreadSnapshot, VoiceSessionSnapshot, }; -use super::updates::AppUpdate; +use super::updates::{AppUpdate, ThreadStreamingDeltaKind}; use super::voice::{VoiceDerivedUpdate, VoiceRealtimeState}; pub struct AppStoreReducer { @@ -35,9 +39,23 @@ pub struct AppStoreReducer { voice_state: VoiceRealtimeState, } +enum ItemMutationUpdate { + Upsert(HydratedConversationItem), + CommandExecutionUpdated { + item_id: String, + status: AppOperationStatus, + exit_code: Option, + duration_ms: Option, + process_id: Option, + output_delta: Option, + }, +} + impl AppStoreReducer { pub fn new() -> Self { - let (updates_tx, _) = broadcast::channel(256); + // Streaming turns can burst small deltas quickly; keep enough headroom so + // native subscribers do not immediately fall into lagged/full-resync mode. + let (updates_tx, _) = broadcast::channel(1024); Self { snapshot: RwLock::new(AppSnapshot::default()), updates_tx, @@ -108,6 +126,7 @@ impl AppStoreReducer { pub fn remove_server(&self, server_id: &str) { let mut removed_thread_keys = Vec::new(); + let agent_directory_version; { let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); snapshot.servers.remove(server_id); @@ -142,12 +161,16 @@ impl AppStoreReducer { { snapshot.voice_session = VoiceSessionSnapshot::default(); } + agent_directory_version = current_agent_directory_version(&snapshot); } self.emit(AppUpdate::ServerRemoved { server_id: server_id.to_string(), }); for key in removed_thread_keys { - self.emit(AppUpdate::ThreadRemoved { key }); + self.emit(AppUpdate::ThreadRemoved { + key, + agent_directory_version, + }); } self.emit(AppUpdate::ActiveThreadChanged { key: None }); } @@ -157,8 +180,14 @@ impl AppStoreReducer { .iter() .map(|info| info.id.clone()) .collect::>(); + let mut upserted_thread_keys = Vec::new(); + let mut updated_thread_keys = Vec::new(); let mut removed_thread_keys = Vec::new(); let mut active_thread_cleared = false; + let mut pending_approvals = None; + let mut pending_user_inputs = None; + let mut voice_session_changed = false; + let agent_directory_version; { let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); let active_thread_key = snapshot.active_thread.clone(); @@ -176,12 +205,22 @@ impl AppStoreReducer { server_id: server_id.to_string(), thread_id: info.id.clone(), }; - let entry = snapshot - .threads - .entry(key.clone()) - .or_insert_with(|| ThreadSnapshot::from_info(server_id, info.clone())); - entry.info = info.clone(); - entry.model = info.model.clone().or(entry.model.clone()); + if let Some(entry) = snapshot.threads.get_mut(&key) { + let next_model = info.model.clone().or_else(|| entry.model.clone()); + let info_changed = entry.info != *info; + let model_changed = entry.model != next_model; + if info_changed || model_changed { + entry.info = info.clone(); + entry.model = next_model; + updated_thread_keys.push(key); + } + } else { + snapshot.threads.insert( + key.clone(), + ThreadSnapshot::from_info(server_id, info.clone()), + ); + upserted_thread_keys.push(key); + } } if snapshot.active_thread.as_ref().is_some_and(|key| { key.server_id == server_id && !incoming_ids.contains(&key.thread_id) @@ -195,6 +234,7 @@ impl AppStoreReducer { active_thread_cleared = true; } } + let approvals_before = snapshot.pending_approvals.len(); snapshot.pending_approvals.retain(|approval| { approval.thread_id.as_deref().is_none_or(|tid| { !removed_thread_keys @@ -202,12 +242,19 @@ impl AppStoreReducer { .any(|key| key.thread_id.as_str() == tid) }) }); + if snapshot.pending_approvals.len() != approvals_before { + pending_approvals = Some(snapshot.pending_approvals.clone()); + } + let pending_user_inputs_before = snapshot.pending_user_inputs.len(); snapshot.pending_user_inputs.retain(|request| { !(request.server_id == server_id && removed_thread_keys .iter() .any(|key| key.thread_id == request.thread_id)) }); + if snapshot.pending_user_inputs.len() != pending_user_inputs_before { + pending_user_inputs = Some(snapshot.pending_user_inputs.clone()); + } if snapshot .voice_session .active_thread @@ -217,15 +264,34 @@ impl AppStoreReducer { }) { snapshot.voice_session = VoiceSessionSnapshot::default(); + voice_session_changed = true; } + agent_directory_version = current_agent_directory_version(&snapshot); } for key in removed_thread_keys { - self.emit(AppUpdate::ThreadRemoved { key }); + self.emit(AppUpdate::ThreadRemoved { + key, + agent_directory_version, + }); + } + for key in upserted_thread_keys { + self.emit_thread_upsert(&key); + } + for key in updated_thread_keys { + self.emit_thread_state_update(&key); + } + if let Some(approvals) = pending_approvals { + self.emit(AppUpdate::PendingApprovalsChanged { approvals }); + } + if let Some(requests) = pending_user_inputs { + self.emit(AppUpdate::PendingUserInputsChanged { requests }); + } + if voice_session_changed { + self.emit(AppUpdate::VoiceSessionChanged); } if active_thread_cleared { self.emit(AppUpdate::ActiveThreadChanged { key: None }); } - self.emit(AppUpdate::FullResync); } pub fn upsert_thread_snapshot(&self, mut thread: ThreadSnapshot) { @@ -234,13 +300,43 @@ impl AppStoreReducer { let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); if let Some(existing) = snapshot.threads.get(&key) { preserve_local_overlay_items(existing, &mut thread); + preserve_queued_follow_ups(existing, &mut thread); } snapshot.threads.insert(key.clone(), thread); } - self.emit(AppUpdate::ThreadChanged { key }); + self.emit_thread_upsert(&key); + } + + pub fn enqueue_thread_follow_up_preview( + &self, + key: &ThreadKey, + preview: QueuedFollowUpPreview, + ) { + if self + .mutate_thread_with_result(key, |thread| { + thread.queued_follow_ups.push(preview); + }) + .is_some() + { + self.emit_thread_state_update(key); + } + } + + pub fn remove_thread_follow_up_preview(&self, key: &ThreadKey, preview_id: &str) { + if self + .mutate_thread_with_result(key, |thread| { + thread + .queued_follow_ups + .retain(|preview| preview.id != preview_id); + }) + .is_some() + { + self.emit_thread_state_update(key); + } } pub fn remove_thread(&self, key: &ThreadKey) { + let agent_directory_version; { let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); snapshot.threads.remove(key); @@ -256,8 +352,12 @@ impl AppStoreReducer { snapshot.pending_user_inputs.retain(|request| { !(request.server_id == key.server_id && request.thread_id == key.thread_id) }); + agent_directory_version = current_agent_directory_version(&snapshot); } - self.emit(AppUpdate::ThreadRemoved { key: key.clone() }); + self.emit(AppUpdate::ThreadRemoved { + key: key.clone(), + agent_directory_version, + }); } pub fn set_active_thread(&self, key: Option) { @@ -352,7 +452,7 @@ impl AppStoreReducer { }; self.emit(AppUpdate::PendingUserInputsChanged { requests }); if let Some(key) = thread_key { - self.emit(AppUpdate::ThreadChanged { key }); + self.emit_thread_upsert(&key); } } @@ -482,47 +582,64 @@ impl AppStoreReducer { }); } UiEvent::ModelRerouted { key, notification } => { - self.mutate_thread(key, |thread| { - thread.model = Some(notification.to_model.clone()); - thread.info.model = Some(notification.to_model.clone()); - upsert_item( - thread, - make_model_rerouted_item( - ¬ification.turn_id, - Some(notification.from_model.clone()), - notification.to_model.clone(), - Some(format_model_reroute_reason(¬ification.reason)), - Some(¬ification.turn_id), - ), - ); - }); + let item = make_model_rerouted_item( + ¬ification.turn_id, + Some(notification.from_model.clone()), + notification.to_model.clone(), + Some(format_model_reroute_reason(¬ification.reason)), + Some(¬ification.turn_id), + ); + if self + .mutate_thread_with_result(key, |thread| { + thread.model = Some(notification.to_model.clone()); + thread.info.model = Some(notification.to_model.clone()); + upsert_item(thread, item.clone()); + }) + .is_some() + { + self.emit_thread_state_update(key); + self.emit_thread_item_upsert(key, &item); + } } UiEvent::TurnStarted { key, turn_id } => { - self.mutate_thread(key, |thread| { - thread.active_turn_id = Some(turn_id.clone()); - thread.info.status = ThreadSummaryStatus::Active; - if thread.info.parent_thread_id.is_some() { - thread.info.agent_status = Some("running".to_string()); - } - }); + if self + .mutate_thread_with_result(key, |thread| { + if !thread.queued_follow_ups.is_empty() { + thread.queued_follow_ups.remove(0); + } + thread.active_turn_id = Some(turn_id.clone()); + thread.info.status = ThreadSummaryStatus::Active; + if thread.info.parent_thread_id.is_some() { + thread.info.agent_status = Some("running".to_string()); + } + }) + .is_some() + { + self.emit_thread_state_update(key); + } } UiEvent::TurnCompleted { key, .. } => { - self.mutate_thread(key, |thread| { - thread.active_turn_id = None; - thread.info.status = ThreadSummaryStatus::Idle; - if thread.info.parent_thread_id.is_some() { - thread.info.agent_status = Some("completed".to_string()); - } - }); + if self + .mutate_thread_with_result(key, |thread| { + thread.active_turn_id = None; + thread.info.status = ThreadSummaryStatus::Idle; + if thread.info.parent_thread_id.is_some() { + thread.info.agent_status = Some("completed".to_string()); + } + }) + .is_some() + { + self.emit_thread_state_update(key); + } } UiEvent::ItemStarted { key, notification } => { if let Some(item) = conversation_item_from_upstream(notification.item.clone()) { - self.mutate_thread(key, |thread| upsert_item(thread, item.clone())); + self.apply_item_update(key, item); } } UiEvent::ItemCompleted { key, notification } => { if let Some(item) = conversation_item_from_upstream(notification.item.clone()) { - self.mutate_thread(key, |thread| upsert_item(thread, item.clone())); + self.apply_item_update(key, item); } } UiEvent::MessageDelta { @@ -530,47 +647,114 @@ impl AppStoreReducer { item_id, delta, } => { - self.mutate_thread(key, |thread| append_assistant_delta(thread, item_id, delta)); + let inserted_placeholder = self + .mutate_thread_with_result(key, |thread| { + append_assistant_delta(thread, item_id, delta) + }) + .unwrap_or(false); + if inserted_placeholder { + self.emit_thread_item_upsert_by_id(key, item_id); + } else { + self.emit_thread_streaming_delta( + key, + item_id, + ThreadStreamingDeltaKind::AssistantText, + delta, + ); + } } UiEvent::ReasoningDelta { key, item_id, delta, } => { - self.mutate_thread(key, |thread| append_reasoning_delta(thread, item_id, delta)); + let updated = self + .mutate_thread_with_result(key, |thread| { + append_reasoning_delta(thread, item_id, delta) + }) + .unwrap_or(false); + if updated { + self.emit_thread_streaming_delta( + key, + item_id, + ThreadStreamingDeltaKind::ReasoningText, + delta, + ); + } else { + self.emit_thread_upsert(key); + } } UiEvent::PlanDelta { key, item_id, delta, } => { - self.mutate_thread(key, |thread| append_plan_delta(thread, item_id, delta)); + let updated = self + .mutate_thread_with_result(key, |thread| { + append_plan_delta(thread, item_id, delta) + }) + .unwrap_or(false); + if updated { + self.emit_thread_streaming_delta( + key, + item_id, + ThreadStreamingDeltaKind::PlanText, + delta, + ); + } else { + self.emit_thread_upsert(key); + } } UiEvent::CommandOutputDelta { key, item_id, delta, } => { - self.mutate_thread(key, |thread| { - append_command_output_delta(thread, item_id, delta) - }); + let updated = self + .mutate_thread_with_result(key, |thread| { + append_command_output_delta(thread, item_id, delta) + }) + .unwrap_or(false); + if updated { + self.emit_thread_streaming_delta( + key, + item_id, + ThreadStreamingDeltaKind::CommandOutput, + delta, + ); + } else { + self.emit_thread_upsert(key); + } } UiEvent::TurnDiffUpdated { key, notification } => { - self.mutate_thread(key, |thread| { - upsert_item( - thread, - make_turn_diff_item( - ¬ification.turn_id, - notification.diff.clone(), - Some(¬ification.turn_id), - ), - ); - }); + let item = make_turn_diff_item( + ¬ification.turn_id, + notification.diff.clone(), + Some(¬ification.turn_id), + ); + if self + .mutate_thread_with_result(key, |thread| upsert_item(thread, item.clone())) + .is_some() + { + self.emit_thread_item_upsert(key, &item); + } } UiEvent::McpToolCallProgress { key, notification } => { - self.mutate_thread(key, |thread| { - append_mcp_progress(thread, ¬ification.item_id, ¬ification.message); - }); + let updated = self + .mutate_thread_with_result(key, |thread| { + append_mcp_progress(thread, ¬ification.item_id, ¬ification.message) + }) + .unwrap_or(false); + if updated { + self.emit_thread_streaming_delta( + key, + ¬ification.item_id, + ThreadStreamingDeltaKind::McpProgress, + ¬ification.message, + ); + } else { + self.emit_thread_upsert(key); + } } UiEvent::ApprovalRequested { approval, .. } => { let approvals = { @@ -616,10 +800,15 @@ impl AppStoreReducer { }); } UiEvent::ContextTokensUpdated { key, used, limit } => { - self.mutate_thread(key, |thread| { - thread.context_tokens_used = Some(*used); - thread.model_context_window = Some(*limit); - }); + if self + .mutate_thread_with_result(key, |thread| { + thread.context_tokens_used = Some(*used); + thread.model_context_window = Some(*limit); + }) + .is_some() + { + self.emit_thread_state_update(key); + } } UiEvent::RealtimeStarted { key, notification } => { self.voice_state.reset_thread(key); @@ -645,7 +834,7 @@ impl AppStoreReducer { key: key.clone(), notification: generated_notification, }); - self.emit(AppUpdate::ThreadChanged { key: key.clone() }); + self.emit_thread_state_update(key); } UiEvent::RealtimeTranscriptUpdated { key, role, text } => { for update in self @@ -771,16 +960,26 @@ impl AppStoreReducer { key: key.clone(), notification: generated_notification, }); - self.emit(AppUpdate::ThreadChanged { key: key.clone() }); + self.emit_thread_state_update(key); } UiEvent::Error { key, message, code } => { if let Some(key) = key { - self.mutate_thread(key, |thread| { - let id = format!("error-{}-{}", key.thread_id, thread.items.len()); - thread - .items - .push(make_error_item(id, message.clone(), *code)); - }); + let item = { + let mut item = None; + self.mutate_thread_with_result(key, |thread| { + let next = make_error_item( + format!("error-{}-{}", key.thread_id, thread.items.len()), + message.clone(), + *code, + ); + thread.items.push(next.clone()); + item = Some(next); + }); + item + }; + if let Some(item) = item { + self.emit_thread_item_upsert(key, &item); + } } } UiEvent::RawNotification { @@ -848,21 +1047,210 @@ impl AppStoreReducer { thread.info.status = info.status; mutate(thread); } - self.emit(AppUpdate::ThreadChanged { key }); + self.emit_thread_upsert(&key); } - fn mutate_thread(&self, key: &ThreadKey, mutate: F) + pub(crate) fn mutate_thread(&self, key: &ThreadKey, mutate: F) where F: FnOnce(&mut ThreadSnapshot), { + if self + .mutate_thread_with_result(key, |thread| { + mutate(thread); + }) + .is_some() { - let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); - let Some(thread) = snapshot.threads.get_mut(key) else { - return; - }; - mutate(thread); + self.emit_thread_upsert(key); + } + } + + pub(crate) fn mutate_thread_with_result(&self, key: &ThreadKey, mutate: F) -> Option + where + F: FnOnce(&mut ThreadSnapshot) -> R, + { + let mut snapshot = self.snapshot.write().expect("app store lock poisoned"); + let thread = snapshot.threads.get_mut(key)?; + Some(mutate(thread)) + } + + pub(crate) fn emit_thread_state_update(&self, key: &ThreadKey) { + let update = { + let snapshot = self.snapshot.read().expect("app store lock poisoned"); + match project_thread_state_update(&snapshot, key) { + Ok(Some((state, session_summary, agent_directory_version))) => { + Some(AppUpdate::ThreadStateUpdated { + state, + session_summary, + agent_directory_version, + }) + } + Ok(None) => None, + Err(error) => { + tracing::error!( + target: "store", + server_id = key.server_id, + thread_id = key.thread_id, + %error, + "failed to project ThreadStateUpdated" + ); + Some(AppUpdate::FullResync) + } + } + }; + if let Some(update) = update { + self.emit(update); + } + } + + pub(crate) fn emit_thread_upsert(&self, key: &ThreadKey) { + let update = { + let snapshot = self.snapshot.read().expect("app store lock poisoned"); + match project_thread_update(&snapshot, key) { + Ok(Some((thread, session_summary, agent_directory_version))) => { + Some(AppUpdate::ThreadUpserted { + thread, + session_summary, + agent_directory_version, + }) + } + Ok(None) => None, + Err(error) => { + tracing::error!( + target: "store", + server_id = key.server_id, + thread_id = key.thread_id, + %error, + "failed to project ThreadUpserted" + ); + Some(AppUpdate::FullResync) + } + } + }; + if let Some(update) = update { + self.emit(update); + } + } + + pub(crate) fn emit_thread_item_upsert(&self, key: &ThreadKey, item: &ConversationItem) { + self.emit(AppUpdate::ThreadItemUpserted { + key: key.clone(), + item: HydratedConversationItem::from(item.clone()), + }); + } + + pub(crate) fn emit_thread_item_upsert_by_id(&self, key: &ThreadKey, item_id: &str) { + let item = { + let snapshot = self.snapshot.read().expect("app store lock poisoned"); + snapshot + .threads + .get(key) + .and_then(|thread| thread.items.iter().find(|item| item.id == item_id).cloned()) + }; + if let Some(item) = item { + self.emit_thread_item_upsert(key, &item); + } + } + + pub(crate) fn emit_thread_command_execution_updated( + &self, + key: &ThreadKey, + item_id: &str, + status: AppOperationStatus, + exit_code: Option, + duration_ms: Option, + process_id: Option, + ) { + self.emit(AppUpdate::ThreadCommandExecutionUpdated { + key: key.clone(), + item_id: item_id.to_string(), + status, + exit_code, + duration_ms, + process_id, + }); + } + + pub(crate) fn emit_thread_streaming_delta( + &self, + key: &ThreadKey, + item_id: &str, + kind: ThreadStreamingDeltaKind, + text: &str, + ) { + self.emit(AppUpdate::ThreadStreamingDelta { + key: key.clone(), + item_id: item_id.to_string(), + kind, + text: text.to_string(), + }); + } + + fn apply_item_update(&self, key: &ThreadKey, item: ConversationItem) { + let result = self.mutate_thread_with_result(key, |thread| { + let existing = thread + .items + .iter() + .find(|existing| existing.id == item.id) + .cloned(); + let queued_count_before = thread.queued_follow_ups.len(); + upsert_item(thread, item.clone()); + if item.is_from_user_turn_boundary && matches!(item.content, ConversationItemContent::User(_)) + { + if !thread.queued_follow_ups.is_empty() { + thread.queued_follow_ups.remove(0); + } + } + ( + classify_item_mutation(existing.as_ref(), &item), + queued_count_before != thread.queued_follow_ups.len(), + ) + }); + + match result { + Some((Some(ItemMutationUpdate::Upsert(item)), queued_changed)) => { + if queued_changed { + self.emit_thread_state_update(key); + } + self.emit(AppUpdate::ThreadItemUpserted { + key: key.clone(), + item, + }); + } + Some((Some(ItemMutationUpdate::CommandExecutionUpdated { + item_id, + status, + exit_code, + duration_ms, + process_id, + output_delta, + }), queued_changed)) => { + if queued_changed { + self.emit_thread_state_update(key); + } + if let Some(delta) = output_delta.filter(|delta| !delta.is_empty()) { + self.emit_thread_streaming_delta( + key, + &item_id, + ThreadStreamingDeltaKind::CommandOutput, + &delta, + ); + } + self.emit_thread_command_execution_updated( + key, + &item_id, + status, + exit_code, + duration_ms, + process_id, + ); + } + Some((None, queued_changed)) => { + if queued_changed { + self.emit_thread_state_update(key); + } + } + None => {} } - self.emit(AppUpdate::ThreadChanged { key: key.clone() }); } fn apply_voice_transcript_update(&self, key: &ThreadKey, update: &AppVoiceTranscriptUpdate) { @@ -908,10 +1296,53 @@ impl AppStoreReducer { AppUpdate::ServerRemoved { server_id } => { tracing::debug!(target: "store", server_id, "emit ServerRemoved") } - AppUpdate::ThreadChanged { key } => { - tracing::debug!(target: "store", server_id = key.server_id, thread_id = key.thread_id, "emit ThreadChanged") - } - AppUpdate::ThreadRemoved { key } => { + AppUpdate::ThreadUpserted { thread, .. } => { + tracing::debug!( + target: "store", + server_id = thread.key.server_id, + thread_id = thread.key.thread_id, + "emit ThreadUpserted" + ) + } + AppUpdate::ThreadStateUpdated { state, .. } => { + tracing::debug!( + target: "store", + server_id = state.key.server_id, + thread_id = state.key.thread_id, + "emit ThreadStateUpdated" + ) + } + AppUpdate::ThreadItemUpserted { key, item } => { + tracing::debug!( + target: "store", + server_id = key.server_id, + thread_id = key.thread_id, + item_id = item.id, + "emit ThreadItemUpserted" + ) + } + AppUpdate::ThreadCommandExecutionUpdated { key, item_id, .. } => { + tracing::debug!( + target: "store", + server_id = key.server_id, + thread_id = key.thread_id, + item_id, + "emit ThreadCommandExecutionUpdated" + ) + } + AppUpdate::ThreadStreamingDelta { + key, item_id, kind, .. + } => { + tracing::trace!( + target: "store", + server_id = key.server_id, + thread_id = key.thread_id, + item_id, + kind = ?kind, + "emit ThreadStreamingDelta" + ) + } + AppUpdate::ThreadRemoved { key, .. } => { tracing::debug!(target: "store", server_id = key.server_id, thread_id = key.thread_id, "emit ThreadRemoved") } AppUpdate::ActiveThreadChanged { key } => { @@ -1044,7 +1475,8 @@ fn upsert_item(thread: &mut ThreadSnapshot, item: crate::conversation::Conversat } } -fn append_assistant_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) { +fn append_assistant_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) -> bool { + let mut inserted_placeholder = false; if !thread.items.iter().any(|item| item.id == item_id) { thread.items.push(ConversationItem { id: item_id.to_string(), @@ -1059,14 +1491,16 @@ fn append_assistant_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &st timestamp: None, is_from_user_turn_boundary: false, }); + inserted_placeholder = true; } let Some(item) = thread.items.iter_mut().find(|item| item.id == item_id) else { - return; + return inserted_placeholder; }; if let ConversationItemContent::Assistant(message) = &mut item.content { message.text.push_str(delta); } + inserted_placeholder } const USER_INPUT_RESPONSE_ITEM_PREFIX: &str = "user-input-response:"; @@ -1087,6 +1521,12 @@ fn preserve_local_overlay_items(source: &ThreadSnapshot, target: &mut ThreadSnap } } +fn preserve_queued_follow_ups(source: &ThreadSnapshot, target: &mut ThreadSnapshot) { + if target.queued_follow_ups.is_empty() { + target.queued_follow_ups = source.queued_follow_ups.clone(); + } +} + fn is_duplicate_overlay_item(local: &ConversationItem, existing: &ConversationItem) -> bool { if local.id == existing.id && local.id.starts_with(USER_INPUT_RESPONSE_ITEM_PREFIX) { return true; @@ -1143,9 +1583,9 @@ fn answered_user_input_item( } } -fn append_reasoning_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) { +fn append_reasoning_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) -> bool { let Some(item) = thread.items.iter_mut().find(|item| item.id == item_id) else { - return; + return false; }; if let ConversationItemContent::Reasoning(reasoning) = &mut item.content { if let Some(last) = reasoning.content.last_mut() { @@ -1153,7 +1593,9 @@ fn append_reasoning_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &st } else { reasoning.content.push(delta.to_string()); } + return true; } + false } #[cfg(test)] @@ -1164,6 +1606,7 @@ mod tests { McpToolCallProgressNotification, ModelRerouteReason, ModelReroutedNotification, TurnDiffUpdatedNotification, }; + use tokio::sync::broadcast::error::TryRecvError; fn make_thread_info(id: &str) -> ThreadInfo { ThreadInfo { @@ -1184,6 +1627,18 @@ mod tests { } } + fn drain_updates(receiver: &mut tokio::sync::broadcast::Receiver) -> Vec { + let mut updates = Vec::new(); + loop { + match receiver.try_recv() { + Ok(update) => updates.push(update), + Err(TryRecvError::Empty) => break, + Err(error) => panic!("unexpected broadcast receive error: {error:?}"), + } + } + updates + } + #[test] fn sync_thread_list_preserves_active_missing_thread() { let reducer = AppStoreReducer::new(); @@ -1194,12 +1649,68 @@ mod tests { reducer .upsert_thread_snapshot(ThreadSnapshot::from_info("srv", make_thread_info("active"))); reducer.set_active_thread(Some(active_key.clone())); + let mut receiver = reducer.subscribe(); reducer.sync_thread_list("srv", &[make_thread_info("other")]); let snapshot = reducer.snapshot(); assert!(snapshot.threads.contains_key(&active_key)); assert_eq!(snapshot.active_thread, Some(active_key)); + let updates = drain_updates(&mut receiver); + assert!( + updates + .iter() + .all(|update| !matches!(update, AppUpdate::FullResync)) + ); + assert!(updates.iter().any(|update| matches!( + update, + AppUpdate::ThreadUpserted { thread, .. } if thread.key.thread_id == "other" + ))); + } + + #[test] + fn sync_thread_list_emits_incremental_updates_without_full_resync() { + let reducer = AppStoreReducer::new(); + let existing_key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "existing".to_string(), + }; + reducer.upsert_thread_snapshot(ThreadSnapshot::from_info( + "srv", + make_thread_info("existing"), + )); + let mut receiver = reducer.subscribe(); + + let mut updated_existing = make_thread_info("existing"); + updated_existing.title = Some("Updated existing".to_string()); + updated_existing.model = Some("gpt-5.4".to_string()); + updated_existing.status = ThreadSummaryStatus::Active; + + let mut inserted = make_thread_info("inserted"); + inserted.model = Some("gpt-5.4".to_string()); + + reducer.sync_thread_list("srv", &[updated_existing.clone(), inserted.clone()]); + + let updates = drain_updates(&mut receiver); + assert!( + updates + .iter() + .all(|update| !matches!(update, AppUpdate::FullResync)) + ); + assert!(updates.iter().any(|update| matches!( + update, + AppUpdate::ThreadStateUpdated { state, .. } + if state.key == existing_key + && state.info == updated_existing + && state.model.as_deref() == Some("gpt-5.4") + ))); + assert!(updates.iter().any(|update| matches!( + update, + AppUpdate::ThreadUpserted { thread, .. } + if thread.key.thread_id == "inserted" + && thread.info == inserted + && thread.model.as_deref() == Some("gpt-5.4") + ))); } #[test] @@ -1434,37 +1945,188 @@ mod tests { assert_eq!(thread.items.len(), 1); assert_eq!(thread.items[0].id, "server-item-1"); } + + #[test] + fn turn_started_consumes_first_queued_follow_up_preview() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + reducer.upsert_thread_snapshot(ThreadSnapshot::from_info("srv", make_thread_info("thread"))); + reducer.enqueue_thread_follow_up_preview( + &key, + QueuedFollowUpPreview { + id: "queued-1".to_string(), + text: "first".to_string(), + }, + ); + reducer.enqueue_thread_follow_up_preview( + &key, + QueuedFollowUpPreview { + id: "queued-2".to_string(), + text: "second".to_string(), + }, + ); + + reducer.apply_ui_event(&UiEvent::TurnStarted { + key: key.clone(), + turn_id: "turn-2".to_string(), + }); + + let snapshot = reducer.snapshot(); + let thread = snapshot.threads.get(&key).expect("thread exists"); + assert_eq!(thread.active_turn_id.as_deref(), Some("turn-2")); + assert_eq!(thread.queued_follow_ups.len(), 1); + assert_eq!(thread.queued_follow_ups[0].id, "queued-2"); + } + + #[test] + fn user_turn_boundary_item_consumes_stale_queued_follow_up_preview() { + let reducer = AppStoreReducer::new(); + let key = ThreadKey { + server_id: "srv".to_string(), + thread_id: "thread".to_string(), + }; + reducer.upsert_thread_snapshot(ThreadSnapshot::from_info("srv", make_thread_info("thread"))); + reducer.enqueue_thread_follow_up_preview( + &key, + QueuedFollowUpPreview { + id: "queued-1".to_string(), + text: "queued follow-up".to_string(), + }, + ); + + reducer.apply_item_update( + &key, + ConversationItem { + id: "user-1".to_string(), + content: ConversationItemContent::User(crate::conversation::UserMessageData { + text: "queued follow-up".to_string(), + image_data_uris: Vec::new(), + }), + source_turn_id: Some("turn-2".to_string()), + source_turn_index: None, + timestamp: None, + is_from_user_turn_boundary: true, + }, + ); + + let snapshot = reducer.snapshot(); + let thread = snapshot.threads.get(&key).expect("thread exists"); + assert!(thread.queued_follow_ups.is_empty()); + } } -fn append_plan_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) { +fn append_plan_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) -> bool { let Some(item) = thread.items.iter_mut().find(|item| item.id == item_id) else { - return; + return false; }; if let ConversationItemContent::ProposedPlan(plan) = &mut item.content { plan.content.push_str(delta); + return true; } + false } -fn append_command_output_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) { +fn append_command_output_delta(thread: &mut ThreadSnapshot, item_id: &str, delta: &str) -> bool { let Some(item) = thread.items.iter_mut().find(|item| item.id == item_id) else { - return; + return false; }; if let ConversationItemContent::CommandExecution(command) = &mut item.content { command .output .get_or_insert_with(String::new) .push_str(delta); + return true; } + false } -fn append_mcp_progress(thread: &mut ThreadSnapshot, item_id: &str, message: &str) { +fn append_mcp_progress(thread: &mut ThreadSnapshot, item_id: &str, message: &str) -> bool { let Some(item) = thread.items.iter_mut().find(|item| item.id == item_id) else { - return; + return false; }; if let ConversationItemContent::McpToolCall(call) = &mut item.content { if !message.trim().is_empty() { call.progress_messages.push(message.to_string()); } + return true; + } + false +} + +fn appended_text_delta(existing: &str, projected: &str) -> Option { + projected + .starts_with(existing) + .then(|| projected[existing.len()..].to_string()) +} + +fn appended_optional_text_delta( + existing: &Option, + projected: &Option, +) -> Option { + match (existing.as_deref(), projected.as_deref()) { + (None, None) => Some(String::new()), + (None, Some(projected)) => Some(projected.to_string()), + (Some(existing), Some(projected)) => appended_text_delta(existing, projected), + (Some(_), None) => None, + } +} + +fn classify_item_mutation( + existing: Option<&ConversationItem>, + item: &ConversationItem, +) -> Option { + let Some(existing) = existing else { + return Some(ItemMutationUpdate::Upsert(HydratedConversationItem::from( + item.clone(), + ))); + }; + + match (&existing.content, &item.content) { + ( + ConversationItemContent::CommandExecution(existing_data), + ConversationItemContent::CommandExecution(projected_data), + ) => { + if existing.id != item.id + || existing.source_turn_id != item.source_turn_id + || existing.source_turn_index != item.source_turn_index + || existing.timestamp != item.timestamp + || existing.is_from_user_turn_boundary != item.is_from_user_turn_boundary + || existing_data.command != projected_data.command + || existing_data.cwd != projected_data.cwd + || existing_data.actions != projected_data.actions + { + return Some(ItemMutationUpdate::Upsert(HydratedConversationItem::from( + item.clone(), + ))); + } + + let output_delta = + appended_optional_text_delta(&existing_data.output, &projected_data.output)?; + let status = AppOperationStatus::from_raw(&projected_data.status); + let status_changed = existing_data.status != projected_data.status + || existing_data.exit_code != projected_data.exit_code + || existing_data.duration_ms != projected_data.duration_ms + || existing_data.process_id != projected_data.process_id; + if output_delta.is_empty() && !status_changed { + None + } else { + Some(ItemMutationUpdate::CommandExecutionUpdated { + item_id: item.id.clone(), + status, + exit_code: projected_data.exit_code, + duration_ms: projected_data.duration_ms, + process_id: projected_data.process_id.clone(), + output_delta: (!output_delta.is_empty()).then_some(output_delta), + }) + } + } + _ if existing.content == item.content => None, + _ => Some(ItemMutationUpdate::Upsert(HydratedConversationItem::from( + item.clone(), + ))), } } diff --git a/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs b/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs index 7f854186..8cdad3d1 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/snapshot.rs @@ -148,6 +148,7 @@ pub struct ThreadSnapshot { pub reasoning_effort: Option, pub items: Vec, pub local_overlay_items: Vec, + pub queued_follow_ups: Vec, pub active_turn_id: Option, pub context_tokens_used: Option, pub model_context_window: Option, @@ -155,6 +156,12 @@ pub struct ThreadSnapshot { pub realtime_session_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueuedFollowUpPreview { + pub id: String, + pub text: String, +} + impl ThreadSnapshot { pub fn from_info(server_id: &str, info: ThreadInfo) -> Self { let key = ThreadKey { @@ -168,6 +175,7 @@ impl ThreadSnapshot { reasoning_effort: None, items: Vec::new(), local_overlay_items: Vec::new(), + queued_follow_ups: Vec::new(), active_turn_id: None, context_tokens_used: None, model_context_window: None, diff --git a/shared/rust-bridge/codex-mobile-client/src/store/updates.rs b/shared/rust-bridge/codex-mobile-client/src/store/updates.rs index 68d59041..845b722f 100644 --- a/shared/rust-bridge/codex-mobile-client/src/store/updates.rs +++ b/shared/rust-bridge/codex-mobile-client/src/store/updates.rs @@ -1,5 +1,17 @@ +use crate::conversation_uniffi::HydratedConversationItem; use crate::types::{PendingApproval, PendingUserInputRequest, ThreadKey, generated}; -use crate::uniffi_shared::{AppVoiceHandoffRequest, AppVoiceTranscriptUpdate}; +use crate::uniffi_shared::{AppOperationStatus, AppVoiceHandoffRequest, AppVoiceTranscriptUpdate}; + +use super::boundary::{AppSessionSummary, AppThreadSnapshot, AppThreadStateRecord}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadStreamingDeltaKind { + AssistantText, + ReasoningText, + PlanText, + CommandOutput, + McpProgress, +} #[derive(Debug, Clone)] pub enum AppUpdate { @@ -10,11 +22,37 @@ pub enum AppUpdate { ServerRemoved { server_id: String, }, - ThreadChanged { + ThreadUpserted { + thread: AppThreadSnapshot, + session_summary: AppSessionSummary, + agent_directory_version: u64, + }, + ThreadStateUpdated { + state: AppThreadStateRecord, + session_summary: AppSessionSummary, + agent_directory_version: u64, + }, + ThreadItemUpserted { + key: ThreadKey, + item: HydratedConversationItem, + }, + ThreadCommandExecutionUpdated { + key: ThreadKey, + item_id: String, + status: AppOperationStatus, + exit_code: Option, + duration_ms: Option, + process_id: Option, + }, + ThreadStreamingDelta { key: ThreadKey, + item_id: String, + kind: ThreadStreamingDeltaKind, + text: String, }, ThreadRemoved { key: ThreadKey, + agent_directory_version: u64, }, ActiveThreadChanged { key: Option, diff --git a/shared/rust-bridge/mobile-log-collector/Cargo.toml b/shared/rust-bridge/mobile-log-collector/Cargo.toml deleted file mode 100644 index 1c11ea33..00000000 --- a/shared/rust-bridge/mobile-log-collector/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "mobile-log-collector" -version = "0.1.0" -edition = "2024" - -[dependencies] -axum = { workspace = true } -clap = { workspace = true } -flate2 = { workspace = true } -mobile-log-shared = { path = "../mobile-log-shared" } -reqwest = { workspace = true } -rusqlite = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -tokio-stream = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } - diff --git a/shared/rust-bridge/mobile-log-collector/src/main.rs b/shared/rust-bridge/mobile-log-collector/src/main.rs deleted file mode 100644 index 82f8376e..00000000 --- a/shared/rust-bridge/mobile-log-collector/src/main.rs +++ /dev/null @@ -1,941 +0,0 @@ -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::net::{IpAddr, SocketAddr}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use axum::body::{Body, Bytes}; -use axum::extract::{ConnectInfo, Query, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; -use axum::response::{Html, IntoResponse, Redirect, Response}; -use axum::routing::get; -use axum::{Json, Router}; -use chrono::{TimeZone, Utc}; -use clap::{Args, Parser, Subcommand}; -use flate2::read::GzDecoder; -use mobile_log_shared::StoredLogEvent; -use rusqlite::{Connection, OptionalExtension, params, params_from_iter, types::Value}; -use serde::{Deserialize, Serialize}; -use tokio::net::TcpListener; -use tokio::sync::broadcast; -use tokio_stream::StreamExt; -use tokio_stream::wrappers::BroadcastStream; -use uuid::Uuid; - -#[derive(Parser)] -#[command(name = "mobile-log-collector")] -#[command(about = "LAN collector for centralized mobile logs")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - Serve(ServeArgs), - Query(ClientArgs), - Tail(ClientArgs), -} - -#[derive(Args, Clone)] -struct ServeArgs { - #[arg(long, default_value = "0.0.0.0:8585")] - bind: String, - #[arg(long)] - data_dir: Option, - #[arg(long, default_value_t = true)] - private_only: bool, -} - -#[derive(Args, Clone)] -struct ClientArgs { - #[arg(long, default_value = "http://127.0.0.1:8585")] - base_url: String, - #[arg(long)] - device_id: Option, - #[arg(long)] - platform: Option, - #[arg(long)] - level: Option, - #[arg(long)] - subsystem: Option, - #[arg(long)] - session_id: Option, - #[arg(long)] - thread_id: Option, - #[arg(long)] - request_id: Option, - #[arg(long)] - start_ms: Option, - #[arg(long)] - end_ms: Option, - #[arg(long, default_value_t = 1000)] - limit: usize, - #[arg(long, default_value_t = false)] - pretty: bool, -} - -#[derive(Clone)] -struct AppState { - inner: Arc, -} - -struct AppStateInner { - private_only: bool, - data_dir: PathBuf, - db: Mutex, - live_tx: broadcast::Sender, -} - -#[derive(Debug, Serialize)] -struct HealthResponse { - ok: bool, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -struct QueryParams { - device_id: Option, - platform: Option, - level: Option, - subsystem: Option, - session_id: Option, - thread_id: Option, - request_id: Option, - start_ms: Option, - end_ms: Option, - limit: Option, -} - -#[derive(Debug)] -struct BatchIngestResult { - duplicate: bool, - events: Vec, -} - -#[derive(Debug)] -struct BatchIndexRow { - batch_id: String, - device_id: String, - platform: Option, - app_version: Option, - first_ts: i64, - last_ts: i64, - event_count: usize, - path: String, -} - -#[derive(Debug)] -struct ApiError(StatusCode, String); - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - (self.0, self.1).into_response() - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let cli = Cli::parse(); - match cli.command { - Command::Serve(args) => serve(args).await?, - Command::Query(args) => run_query(args).await?, - Command::Tail(args) => run_tail(args).await?, - } - Ok(()) -} - -async fn serve(args: ServeArgs) -> Result<(), Box> { - let data_dir = args.data_dir.unwrap_or_else(default_data_dir); - std::fs::create_dir_all(&data_dir)?; - let db_path = data_dir.join("collector.sqlite3"); - let connection = Connection::open(db_path)?; - init_db(&connection)?; - let (live_tx, _) = broadcast::channel(10_000); - - let state = AppState { - inner: Arc::new(AppStateInner { - private_only: args.private_only, - data_dir, - db: Mutex::new(connection), - live_tx: live_tx.clone(), - }), - }; - - // Print incoming events to stdout (like a built-in tail) - let mut console_rx = live_tx.subscribe(); - tokio::spawn(async move { - loop { - match console_rx.recv().await { - Ok(event) => { - let ts = chrono::Utc - .timestamp_millis_opt(event.timestamp_ms) - .single() - .map(|t| t.format("%H:%M:%S%.3f").to_string()) - .unwrap_or_else(|| event.timestamp_ms.to_string()); - let level_color = match event.level.as_str() { - "ERROR" => "\x1b[31m", - "WARN" => "\x1b[33m", - "INFO" => "\x1b[32m", - "DEBUG" => "\x1b[36m", - "TRACE" => "\x1b[90m", - _ => "\x1b[0m", - }; - let reset = "\x1b[0m"; - let dim = "\x1b[90m"; - let device = if event.device_name.is_empty() { - &event.device_id - } else { - &event.device_name - }; - let sub = event - .subsystem - .rsplit("::") - .next() - .unwrap_or(&event.subsystem); - eprint!( - "{dim}{ts}{reset} {dim}[{device}]{reset} {level_color}{:<5}{reset} {dim}{}{reset} {}", - event.level, sub, event.message - ); - if let Some(ref fields) = event.fields_json { - if fields != "null" && !fields.is_empty() { - eprint!(" {dim}{fields}{reset}"); - } - } - eprintln!(); - } - Err(broadcast::error::RecvError::Lagged(n)) => { - eprintln!("\x1b[33m[collector] skipped {n} events\x1b[0m"); - } - Err(broadcast::error::RecvError::Closed) => break, - } - } - }); - - let app = Router::new() - .route("/", get(root_redirect)) - .route("/tail", get(tail_ui)) - .route("/static/renderjson.js", get(renderjson_asset)) - .route("/healthz", get(healthz)) - .route("/v1/logs", axum::routing::post(post_logs)) - .route("/v1/query", get(query_logs)) - .route("/v1/tail", get(tail_logs)) - .with_state(state); - - let addr: SocketAddr = args.bind.parse()?; - let listener = TcpListener::bind(&addr).await?; - let local_addr = listener.local_addr()?; - eprintln!("\x1b[32m[collector]\x1b[0m listening on {addr}"); - eprintln!( - "\x1b[32m[collector]\x1b[0m web ui: {}", - local_tail_ui_url(local_addr) - ); - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await?; - Ok(()) -} - -async fn healthz() -> Json { - Json(HealthResponse { ok: true }) -} - -async fn root_redirect() -> Redirect { - Redirect::temporary("/tail") -} - -async fn tail_ui() -> Html<&'static str> { - Html(include_str!("tail_ui.html")) -} - -async fn renderjson_asset() -> impl IntoResponse { - ( - [( - axum::http::header::CONTENT_TYPE, - HeaderValue::from_static("text/javascript; charset=utf-8"), - )], - include_str!("renderjson.js"), - ) -} - -async fn post_logs( - State(state): State, - ConnectInfo(addr): ConnectInfo, - headers: HeaderMap, - body: Bytes, -) -> Result { - authorize(&state, &addr)?; - let batch_id = required_header(&headers, "X-Batch-Id")?; - let device_id = required_header(&headers, "X-Device-Id")?; - let state_clone = state.clone(); - let body_vec = body.to_vec(); - - let result = tokio::task::spawn_blocking(move || { - ingest_batch(&state_clone, &batch_id, &device_id, &body_vec) - }) - .await - .map_err(|err| { - ApiError( - StatusCode::INTERNAL_SERVER_ERROR, - format!("ingest join error: {err}"), - ) - })? - .map_err(|err| ApiError(StatusCode::BAD_REQUEST, err))?; - - if !result.duplicate { - for event in result.events { - let _ = state.inner.live_tx.send(event); - } - } - - Ok(StatusCode::NO_CONTENT) -} - -async fn query_logs( - State(state): State, - ConnectInfo(addr): ConnectInfo, - Query(params): Query, - _headers: HeaderMap, -) -> Result { - authorize(&state, &addr)?; - let state_clone = state.clone(); - let rows = tokio::task::spawn_blocking(move || query_events(&state_clone, ¶ms)) - .await - .map_err(|err| { - ApiError( - StatusCode::INTERNAL_SERVER_ERROR, - format!("query join error: {err}"), - ) - })? - .map_err(|err| ApiError(StatusCode::INTERNAL_SERVER_ERROR, err))?; - - let mut body = String::new(); - for row in rows { - body.push_str(&serde_json::to_string(&row).map_err(internal_error)?); - body.push('\n'); - } - - Ok(( - [( - axum::http::header::CONTENT_TYPE, - HeaderValue::from_static("application/x-ndjson"), - )], - body, - ) - .into_response()) -} - -async fn tail_logs( - State(state): State, - ConnectInfo(addr): ConnectInfo, - Query(params): Query, - _headers: HeaderMap, -) -> Result { - authorize(&state, &addr)?; - let rx = state.inner.live_tx.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(move |item| { - let params = params.clone(); - let event = item.ok()?; - if matches_event(&event, ¶ms) { - let line = serde_json::to_vec(&event).ok()?; - Some(Ok::(Bytes::from( - [line, b"\n".to_vec()].concat(), - ))) - } else { - None - } - }); - - Ok(( - [( - axum::http::header::CONTENT_TYPE, - HeaderValue::from_static("application/x-ndjson"), - )], - Body::from_stream(stream), - ) - .into_response()) -} - -fn ingest_batch( - state: &AppState, - batch_id: &str, - device_id: &str, - body: &[u8], -) -> Result { - let duplicate = { - let db = state - .inner - .db - .lock() - .map_err(|_| "collector database lock poisoned".to_string())?; - db.query_row( - "SELECT 1 FROM batches WHERE batch_id = ?1", - params![batch_id], - |_| Ok(()), - ) - .optional() - .map_err(|err| format!("failed to query batch index: {err}"))? - .is_some() - }; - if duplicate { - return Ok(BatchIngestResult { - duplicate: true, - events: Vec::new(), - }); - } - - let events = decode_batch(body)?; - if events.is_empty() { - return Err("batch contained no log events".to_string()); - } - - let first = events.first().expect("non-empty batch"); - let last = events.last().expect("non-empty batch"); - let batch_dir = batch_dir_for(&state.inner.data_dir, first.timestamp_ms, device_id); - std::fs::create_dir_all(&batch_dir) - .map_err(|err| format!("failed to create batch dir: {err}"))?; - let path = batch_dir.join(format!("{batch_id}.ndjson.gz")); - std::fs::write(&path, body).map_err(|err| format!("failed to write batch file: {err}"))?; - - let row = BatchIndexRow { - batch_id: batch_id.to_string(), - device_id: device_id.to_string(), - platform: Some(first.platform.clone()), - app_version: first.app_version.clone(), - first_ts: first.timestamp_ms, - last_ts: last.timestamp_ms, - event_count: events.len(), - path: path.to_string_lossy().to_string(), - }; - - let db = state - .inner - .db - .lock() - .map_err(|_| "collector database lock poisoned".to_string())?; - db.execute( - "INSERT INTO batches (batch_id, device_id, platform, app_version, first_ts, last_ts, event_count, path, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - row.batch_id, - row.device_id, - row.platform, - row.app_version, - row.first_ts, - row.last_ts, - row.event_count as i64, - row.path, - Utc::now().timestamp_millis(), - ], - ) - .map_err(|err| format!("failed to insert batch metadata: {err}"))?; - - Ok(BatchIngestResult { - duplicate: false, - events, - }) -} - -fn query_events(state: &AppState, params: &QueryParams) -> Result, String> { - let rows = { - let db = state - .inner - .db - .lock() - .map_err(|_| "collector database lock poisoned".to_string())?; - let mut sql = String::from( - "SELECT batch_id, device_id, platform, app_version, first_ts, last_ts, event_count, path FROM batches WHERE 1=1", - ); - let mut bind_values = Vec::::new(); - if params.device_id.is_some() { - sql.push_str(" AND device_id = ?"); - bind_values.push(Value::from( - params.device_id.clone().expect("device_id checked above"), - )); - } - if params.platform.is_some() { - sql.push_str(" AND platform = ?"); - bind_values.push(Value::from( - params.platform.clone().expect("platform checked above"), - )); - } - if params.start_ms.is_some() { - sql.push_str(" AND last_ts >= ?"); - bind_values.push(Value::from( - params.start_ms.expect("start_ms checked above"), - )); - } - if params.end_ms.is_some() { - sql.push_str(" AND first_ts <= ?"); - bind_values.push(Value::from(params.end_ms.expect("end_ms checked above"))); - } - sql.push_str(" ORDER BY first_ts ASC"); - - let mut stmt = db - .prepare(&sql) - .map_err(|err| format!("failed to prepare query: {err}"))?; - let mapped = stmt - .query_map(params_from_iter(bind_values.iter()), |row| { - Ok(BatchIndexRow { - batch_id: row.get(0)?, - device_id: row.get(1)?, - platform: row.get(2)?, - app_version: row.get(3)?, - first_ts: row.get(4)?, - last_ts: row.get(5)?, - event_count: row.get::<_, i64>(6)? as usize, - path: row.get(7)?, - }) - }) - .map_err(|err| format!("failed to execute query: {err}"))?; - - let mut rows = Vec::new(); - for row in mapped { - rows.push(row.map_err(|err| format!("failed to decode row: {err}"))?); - } - rows - }; - - let limit = params.limit.unwrap_or(1000); - let mut events = Vec::new(); - for row in rows { - if events.len() >= limit { - break; - } - for event in read_batch_file(Path::new(&row.path))? { - if matches_event(&event, params) { - events.push(event); - if events.len() >= limit { - break; - } - } - } - } - Ok(events) -} - -fn matches_event(event: &StoredLogEvent, params: &QueryParams) -> bool { - if let Some(device_id) = params.device_id.as_deref() - && event.device_id != device_id - { - return false; - } - if let Some(platform) = params.platform.as_deref() - && event.platform != platform - { - return false; - } - if let Some(level) = params.level.as_deref() - && event.level != level.to_ascii_uppercase() - { - return false; - } - if let Some(subsystem) = params.subsystem.as_deref() - && event.subsystem != subsystem - { - return false; - } - if let Some(session_id) = params.session_id.as_deref() - && event.session_id.as_deref() != Some(session_id) - { - return false; - } - if let Some(thread_id) = params.thread_id.as_deref() - && event.thread_id.as_deref() != Some(thread_id) - { - return false; - } - if let Some(request_id) = params.request_id.as_deref() - && event.request_id.as_deref() != Some(request_id) - { - return false; - } - if let Some(start_ms) = params.start_ms - && event.timestamp_ms < start_ms - { - return false; - } - if let Some(end_ms) = params.end_ms - && event.timestamp_ms > end_ms - { - return false; - } - true -} - -fn decode_batch(body: &[u8]) -> Result, String> { - let decoder = GzDecoder::new(body); - let reader = BufReader::new(decoder); - let mut events = Vec::new(); - for line in reader.lines() { - let line = line.map_err(|err| format!("failed to read batch line: {err}"))?; - if line.trim().is_empty() { - continue; - } - let event: StoredLogEvent = - serde_json::from_str(&line).map_err(|err| format!("invalid event json: {err}"))?; - events.push(event); - } - Ok(events) -} - -fn read_batch_file(path: &Path) -> Result, String> { - let file = - File::open(path).map_err(|err| format!("failed to open {}: {err}", path.display()))?; - let decoder = GzDecoder::new(file); - let reader = BufReader::new(decoder); - let mut events = Vec::new(); - for line in reader.lines() { - let line = line.map_err(|err| format!("failed to read {}: {err}", path.display()))?; - if line.trim().is_empty() { - continue; - } - events - .push(serde_json::from_str(&line).map_err(|err| format!("invalid event json: {err}"))?); - } - Ok(events) -} - -fn init_db(connection: &Connection) -> rusqlite::Result<()> { - connection.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS batches ( - batch_id TEXT PRIMARY KEY, - device_id TEXT NOT NULL, - platform TEXT, - app_version TEXT, - first_ts INTEGER NOT NULL, - last_ts INTEGER NOT NULL, - event_count INTEGER NOT NULL, - path TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_batches_device_id ON batches(device_id); - CREATE INDEX IF NOT EXISTS idx_batches_platform ON batches(platform); - CREATE INDEX IF NOT EXISTS idx_batches_first_ts ON batches(first_ts); - CREATE INDEX IF NOT EXISTS idx_batches_last_ts ON batches(last_ts); - "#, - ) -} - -fn authorize(state: &AppState, addr: &SocketAddr) -> Result<(), ApiError> { - if state.inner.private_only && !is_private(addr.ip()) { - return Err(ApiError( - StatusCode::FORBIDDEN, - "collector only accepts private-network clients".into(), - )); - } - Ok(()) -} - -fn required_header(headers: &HeaderMap, name: &'static str) -> Result { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .map(str::to_string) - .ok_or_else(|| { - ApiError( - StatusCode::BAD_REQUEST, - format!("missing required header: {name}"), - ) - }) -} - -fn is_private(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => ip.is_private() || ip.is_loopback(), - IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local(), - } -} - -fn batch_dir_for(data_dir: &Path, timestamp_ms: i64, device_id: &str) -> PathBuf { - let date = Utc - .timestamp_millis_opt(timestamp_ms) - .single() - .unwrap_or_else(Utc::now) - .format("%Y-%m-%d") - .to_string(); - data_dir.join("batches").join(date).join(device_id) -} - -fn default_data_dir() -> PathBuf { - if let Ok(home) = std::env::var("HOME") - && !home.is_empty() - { - return PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("mobile-log-collector"); - } - std::env::temp_dir().join(format!("mobile-log-collector-{}", Uuid::new_v4())) -} - -fn local_tail_ui_url(addr: SocketAddr) -> String { - let host = match addr.ip() { - IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(), - IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(), - IpAddr::V4(ip) => ip.to_string(), - IpAddr::V6(ip) => format!("[{ip}]"), - }; - format!("http://{host}:{}/tail", addr.port()) -} - -async fn run_query(args: ClientArgs) -> Result<(), Box> { - let client = reqwest::Client::new(); - let response = client - .get(format!("{}/v1/query", args.base_url.trim_end_matches('/'))) - .query(&QueryParams { - device_id: args.device_id, - platform: args.platform, - level: args.level, - subsystem: args.subsystem, - session_id: args.session_id, - thread_id: args.thread_id, - request_id: args.request_id, - start_ms: args.start_ms, - end_ms: args.end_ms, - limit: Some(args.limit), - }) - .send() - .await?; - let body = response.text().await?; - if !args.pretty { - print!("{body}"); - return Ok(()); - } - - for line in body.lines() { - if line.trim().is_empty() { - continue; - } - let event: StoredLogEvent = serde_json::from_str(line)?; - println!( - "[{}] {} {} {}", - event.timestamp_ms, event.level, event.subsystem, event.message - ); - } - Ok(()) -} - -async fn run_tail(args: ClientArgs) -> Result<(), Box> { - let pretty = args.pretty; - let client = reqwest::Client::new(); - let response = client - .get(format!("{}/v1/tail", args.base_url.trim_end_matches('/'))) - .query(&QueryParams { - device_id: args.device_id, - platform: args.platform, - level: args.level, - subsystem: args.subsystem, - session_id: args.session_id, - thread_id: args.thread_id, - request_id: args.request_id, - start_ms: args.start_ms, - end_ms: args.end_ms, - limit: Some(args.limit), - }) - .send() - .await?; - let mut response = response; - let mut buf = String::new(); - while let Some(chunk) = response.chunk().await? { - buf.push_str(&String::from_utf8_lossy(&chunk)); - while let Some(newline_pos) = buf.find('\n') { - let line = buf[..newline_pos].to_string(); - buf.drain(..=newline_pos); - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if !pretty { - println!("{trimmed}"); - continue; - } - if let Ok(event) = serde_json::from_str::(trimmed) { - let ts = chrono::Utc - .timestamp_millis_opt(event.timestamp_ms) - .single() - .map(|t| t.format("%H:%M:%S%.3f").to_string()) - .unwrap_or_else(|| event.timestamp_ms.to_string()); - let level_color = match event.level.as_str() { - "ERROR" => "\x1b[31m", - "WARN" => "\x1b[33m", - "INFO" => "\x1b[32m", - "DEBUG" => "\x1b[36m", - "TRACE" => "\x1b[90m", - _ => "\x1b[0m", - }; - let reset = "\x1b[0m"; - let dim = "\x1b[90m"; - let sub = event - .subsystem - .rsplit("::") - .next() - .unwrap_or(&event.subsystem); - print!( - "{dim}{ts}{reset} {level_color}{:<5}{reset} {dim}{}{reset} {}", - event.level, sub, event.message - ); - if let Some(ref fields) = event.fields_json { - if fields != "null" && !fields.is_empty() { - print!(" {dim}{fields}{reset}"); - } - } - println!(); - } else { - println!("{trimmed}"); - } - } - } - Ok(()) -} - -fn internal_error(err: serde_json::Error) -> ApiError { - ApiError( - StatusCode::INTERNAL_SERVER_ERROR, - format!("serialization error: {err}"), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use flate2::Compression; - use flate2::write::GzEncoder; - use std::io::Write; - use std::net::Ipv4Addr; - use std::sync::{Arc, Mutex}; - - fn encode_events(events: &[StoredLogEvent]) -> Vec { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - for event in events { - serde_json::to_writer(&mut encoder, event).expect("encode event"); - encoder.write_all(b"\n").expect("newline"); - } - encoder.finish().expect("finish") - } - - #[test] - fn decode_batch_round_trips() { - let event = StoredLogEvent { - timestamp_ms: 123, - level: "INFO".into(), - source: "ios".into(), - platform: "ios".into(), - subsystem: "test".into(), - category: "roundtrip".into(), - message: "hello".into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json: None, - device_id: "device-1".into(), - device_name: "phone".into(), - app_version: Some("1.0".into()), - build: Some("1".into()), - process_id: 7, - }; - - let decoded = decode_batch(&encode_events(&[event.clone()])).expect("decode"); - assert_eq!(decoded, vec![event]); - } - - #[test] - fn private_ip_detection_accepts_lan_and_loopback() { - assert!(is_private(IpAddr::V4(Ipv4Addr::LOCALHOST))); - assert!(is_private(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 8)))); - assert!(!is_private(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); - } - - #[test] - fn local_tail_ui_url_prefers_loopback_for_unspecified_bindings() { - let ipv4 = local_tail_ui_url(SocketAddr::from(([0, 0, 0, 0], 8585))); - let ipv6 = local_tail_ui_url(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 8585))); - - assert_eq!(ipv4, "http://127.0.0.1:8585/tail"); - assert_eq!(ipv6, "http://[::1]:8585/tail"); - } - - #[test] - fn query_events_accepts_missing_optional_filters() { - let temp_dir = - std::env::temp_dir().join(format!("mobile-log-collector-test-{}", Uuid::new_v4())); - std::fs::create_dir_all(&temp_dir).expect("create temp dir"); - let db_path = temp_dir.join("collector.sqlite3"); - let connection = Connection::open(&db_path).expect("open db"); - init_db(&connection).expect("init db"); - - let batch_path = temp_dir.join("batch.ndjson.gz"); - let event = StoredLogEvent { - timestamp_ms: 456, - level: "INFO".into(), - source: "android".into(), - platform: "android".into(), - subsystem: "test".into(), - category: "query".into(), - message: "collector query regression".into(), - session_id: None, - server_id: None, - thread_id: None, - request_id: None, - payload_json: None, - fields_json: None, - device_id: "device-2".into(), - device_name: "emulator".into(), - app_version: Some("0.1.0".into()), - build: Some("5".into()), - process_id: 42, - }; - std::fs::write(&batch_path, encode_events(std::slice::from_ref(&event))) - .expect("write batch"); - connection - .execute( - "INSERT INTO batches (batch_id, device_id, platform, app_version, first_ts, last_ts, event_count, path, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - "batch-1", - event.device_id, - event.platform, - event.app_version, - event.timestamp_ms, - event.timestamp_ms, - 1, - batch_path.to_string_lossy().to_string(), - event.timestamp_ms, - ], - ) - .expect("insert batch"); - - let (live_tx, _) = broadcast::channel(8); - let state = AppState { - inner: Arc::new(AppStateInner { - private_only: false, - data_dir: temp_dir.clone(), - db: Mutex::new(connection), - live_tx, - }), - }; - - let rows = query_events( - &state, - &QueryParams { - device_id: None, - platform: None, - level: None, - subsystem: None, - session_id: None, - thread_id: None, - request_id: None, - start_ms: None, - end_ms: None, - limit: Some(10), - }, - ) - .expect("query without filters"); - - assert_eq!(rows, vec![event]); - - std::fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); - } -} diff --git a/shared/rust-bridge/mobile-log-collector/src/renderjson.js b/shared/rust-bridge/mobile-log-collector/src/renderjson.js deleted file mode 100644 index c9836c1f..00000000 --- a/shared/rust-bridge/mobile-log-collector/src/renderjson.js +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright © 2013-2017 David Caldwell -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -var module, window, define, renderjson=(function() { - var themetext = function() { - var spans = []; - while (arguments.length) - spans.push(append(span(Array.prototype.shift.call(arguments)), - text(Array.prototype.shift.call(arguments)))); - return spans; - }; - var append = function() { - var el = Array.prototype.shift.call(arguments); - for (var a=0; a 0 && type !== "string") - show(); - return el; - }; - - if (json === null) return themetext(null, my_indent, "keyword", "null"); - if (json === void 0) return themetext(null, my_indent, "keyword", "undefined"); - - if (typeof(json) === "string" && json.length > options.max_string_length) - return disclosure('"', json.substr(0, options.max_string_length) + " ...", '"', "string", function() { - return append(span("string"), themetext(null, my_indent, "string", JSON.stringify(json))); - }); - - if (typeof(json) !== "object" || [Number, String, Boolean, Date].indexOf(json.constructor) >= 0) - return themetext(null, my_indent, typeof(json), JSON.stringify(json)); - - if (json.constructor === Array) { - if (json.length === 0) return themetext(null, my_indent, "array syntax", "[]"); - - return disclosure("[", options.collapse_msg(json.length), "]", "array", function() { - var as = append(span("array"), themetext("array syntax", "[", null, "\n")); - for (var i=0; i - - - - - Mobile Log Collector Tail - - - -
-

Mobile Log Collector Tail

-
-
Status: Booting
-
Visible events: 0
-
History preload: 200
-
-
- - -
-
-
-
- -
-
Waiting for log events...
-
-
- - - - - diff --git a/shared/rust-bridge/mobile-log-shared/Cargo.toml b/shared/rust-bridge/mobile-log-shared/Cargo.toml deleted file mode 100644 index 704a7b99..00000000 --- a/shared/rust-bridge/mobile-log-shared/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "mobile-log-shared" -version = "0.1.0" -edition = "2024" - -[dependencies] -serde = { workspace = true } - diff --git a/shared/rust-bridge/mobile-log-shared/src/lib.rs b/shared/rust-bridge/mobile-log-shared/src/lib.rs deleted file mode 100644 index 9b72e0ad..00000000 --- a/shared/rust-bridge/mobile-log-shared/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StoredLogEvent { - pub timestamp_ms: i64, - pub level: String, - pub source: String, - pub platform: String, - pub subsystem: String, - pub category: String, - pub message: String, - pub session_id: Option, - pub server_id: Option, - pub thread_id: Option, - pub request_id: Option, - pub payload_json: Option, - pub fields_json: Option, - pub device_id: String, - pub device_name: String, - pub app_version: Option, - pub build: Option, - pub process_id: u32, -}