diff --git a/.gitignore b/.gitignore index 014912b9..f0e629bf 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ RxCode/GoogleService-Info.plist RxCodeMobile/GoogleService-Info.plist RxCodeAndroid/app/google-services.json .env +*.log diff --git a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift index fd271d3d..0bfcc14b 100644 --- a/Packages/Sources/RxCodeMarkdown/MarkdownView.swift +++ b/Packages/Sources/RxCodeMarkdown/MarkdownView.swift @@ -286,8 +286,14 @@ private struct MarkdownDocumentView: View { } private func opacity(for range: Range) -> Double { + // A block-level element fades in only when it first appears. We key the + // fade off the segment containing the block's start offset rather than + // any overlapping segment. Otherwise, appending content to an existing + // block during streaming (e.g. new table rows or code lines) keeps + // overlapping the newest fade segment and re-fades the whole block on + // every chunk — which shows up as the block blinking repeatedly. fadeSegments - .filter { $0.range.overlaps(range) } + .filter { $0.range.contains(range.lowerBound) } .map(\.opacity) .min() ?? 1 } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 5f9989de..56011fa2 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; + DFA8E3B42FD1A27F00F33E7F /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = DFA8E3B32FD1A27F00F33E7F /* SDWebImageSVGCoder */; }; DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DFAA00022FCD34B3002D9562 /* JSONSchema */; }; DFAA01102FCD34B3002D9562 /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DFAA01112FCD34B3002D9562 /* JSONSchema */; }; E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62000002FCB000100000001 /* MemoryIntentTests.swift */; }; @@ -264,6 +265,7 @@ DF230BA32FBC73BA008929A6 /* RxCodeSync in Frameworks */, DF230BA12FBC73BA008929A6 /* RxCodeCore in Frameworks */, DF230B9F2FBC73BA008929A6 /* RxCodeChatKit in Frameworks */, + DFA8E3B42FD1A27F00F33E7F /* SDWebImageSVGCoder in Frameworks */, FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */, FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */, DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */, @@ -510,6 +512,7 @@ DF4668392FCDB56B002D9562 /* JSONSchemaForm */, DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */, DFAA01112FCD34B3002D9562 /* JSONSchema */, + DFA8E3B32FD1A27F00F33E7F /* SDWebImageSVGCoder */, ); productName = RxCodeMobile; productReference = DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */; @@ -685,6 +688,7 @@ DF462DC72FC73FA8002D9562 /* XCRemoteSwiftPackageReference "RxAuthSwift" */, DF4652662FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-jsonschema-form" */, DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */, + DFA8E3B22FD1A27F00F33E7F /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -1546,6 +1550,14 @@ kind = branch; }; }; + DFA8E3B22FD1A27F00F33E7F /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.8.0; + }; + }; DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sirily11/swift-json-schema"; @@ -1680,6 +1692,11 @@ isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; }; + DFA8E3B32FD1A27F00F33E7F /* SDWebImageSVGCoder */ = { + isa = XCSwiftPackageProductDependency; + package = DFA8E3B22FD1A27F00F33E7F /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; + productName = SDWebImageSVGCoder; + }; DFAA00022FCD34B3002D9562 /* JSONSchema */ = { isa = XCSwiftPackageProductDependency; package = DFAA00032FCD34B3002D9562 /* XCRemoteSwiftPackageReference "swift-json-schema" */; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 406d1a26..efe23aba 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "556f462e7c0c84051129b525014f8f88c505f51ef56a64fbb8e65638429bb39c", + "originHash" : "037027ac785e88eafec21a5d2ded918dff49adb8ca86008f1fff4211c09b1c39", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -127,6 +127,24 @@ "version" : "1.1.1" } }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "sdwebimagesvgcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder", + "state" : { + "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version" : "1.8.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/RxCode/App/AppState+MobileRemote.swift b/RxCode/App/AppState+MobileRemote.swift index 200b9935..79704fa4 100644 --- a/RxCode/App/AppState+MobileRemote.swift +++ b/RxCode/App/AppState+MobileRemote.swift @@ -48,8 +48,10 @@ extension AppState { } func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { + logger.info("[MobileSync] skill catalog handler start requestID=\(request.clientRequestID.uuidString, privacy: .public) forceRefresh=\(request.forceRefresh, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) let sources = await mobileSkillSources() + logger.info("[MobileSync] skill catalog handler result requestID=\(request.clientRequestID.uuidString, privacy: .public) plugins=\(plugins.count, privacy: .public) sources=\(sources.count, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") let result = SkillCatalogResultPayload( clientRequestID: request.clientRequestID, ok: true, @@ -207,14 +209,18 @@ extension AppState { } func handleMobileACPRegistryRequest(_ request: ACPRegistryRequestPayload, fromHex: String) async { + logger.info("[MobileSync] acp registry handler start requestID=\(request.clientRequestID.uuidString, privacy: .public) forceRefresh=\(request.forceRefresh, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") await refreshACPRegistry(forceRefresh: request.forceRefresh) let ok = acpRegistry != nil + let agents = mobileACPRegistryAgents() + let clients = mobileACPClients() + logger.info("[MobileSync] acp registry handler result requestID=\(request.clientRequestID.uuidString, privacy: .public) ok=\(ok, privacy: .public) agents=\(agents.count, privacy: .public) clients=\(clients.count, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") let result = ACPRegistryResultPayload( clientRequestID: request.clientRequestID, ok: ok, errorMessage: ok ? nil : "Could not load the ACP agent registry.", - registryAgents: mobileACPRegistryAgents(), - installedClients: mobileACPClients() + registryAgents: agents, + installedClients: clients ) await MobileSyncService.shared.send(.acpRegistryResult(result), toHex: fromHex) } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 466fe0fb..8d7d9d0a 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -6601,6 +6601,28 @@ } } }, + "hook.pullRequest.creating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creating pull request…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "풀 리퀘스트 생성 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在创建拉取请求…" + } + } + } + }, "hook.secrets.downloading" : { "localizations" : { "en" : { @@ -15505,4 +15527,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/RxCode/Views/ProjectWindowView.swift b/RxCode/Views/ProjectWindowView.swift index 4cfe7d64..932e7909 100644 --- a/RxCode/Views/ProjectWindowView.swift +++ b/RxCode/Views/ProjectWindowView.swift @@ -77,6 +77,7 @@ struct ProjectWindowView: View { .onAppear { windowState.isProjectWindow = true } + .hookUI() .sheet(item: Bindable(windowState).inspectorFile) { file in FileInspectorView(filePath: file.path, fileName: file.name) .frame(minWidth: 1000, idealWidth: 1400, maxWidth: 1920, diff --git a/RxCode/Views/Sidebar/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index 69db88a4..c6e6c5ba 100644 --- a/RxCode/Views/Sidebar/ProjectListView.swift +++ b/RxCode/Views/Sidebar/ProjectListView.swift @@ -29,8 +29,12 @@ struct ProjectListView: View { private func startCreatePR(_ project: Project) { guard creatingPRProjectId == nil else { return } creatingPRProjectId = project.id + appState.hookProgressStatus = "hook.pullRequest.creating" Task { @MainActor in - defer { creatingPRProjectId = nil } + defer { + creatingPRProjectId = nil + appState.hookProgressStatus = nil + } do { let url = try await appState.createPullRequestForCurrentBranch(project: project) NSWorkspace.shared.open(url) diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index e355fa18..63fbc19c 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -475,8 +475,12 @@ private struct ProjectTreeRow: View { private func startCreatePR() { guard !creatingPR else { return } creatingPR = true + appState.hookProgressStatus = "hook.pullRequest.creating" Task { @MainActor in - defer { creatingPR = false } + defer { + creatingPR = false + appState.hookProgressStatus = nil + } do { let url = try await appState.createPullRequestForCurrentBranch(project: project) NSWorkspace.shared.open(url) diff --git a/RxCodeAndroid/app/build.gradle.kts b/RxCodeAndroid/app/build.gradle.kts index e2bea595..05afc458 100644 --- a/RxCodeAndroid/app/build.gradle.kts +++ b/RxCodeAndroid/app/build.gradle.kts @@ -124,6 +124,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.coil.compose) + implementation(libs.coil.svg) implementation(libs.compose.markdown) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt index 8a87f21e..29e49630 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt @@ -2,12 +2,15 @@ package app.rxlab.rxcode import android.app.Application import android.util.Log +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.decode.SvgDecoder import com.google.firebase.FirebaseApp import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class RxCodeApplication : Application() { +class RxCodeApplication : Application(), ImageLoaderFactory { override fun onCreate() { super.onCreate() Log.w(TAG, "RxCodeApplication launched") @@ -20,6 +23,15 @@ class RxCodeApplication : Application() { } } + // ACP registry icons (and some autopilot avatars) are served as SVGs, which + // Android's native ImageDecoder can't handle ("Failed to create image decoder + // … 'unimplemented'"). Register Coil's SvgDecoder on the app-wide ImageLoader so + // every AsyncImage can render them. + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .components { add(SvgDecoder.Factory()) } + .build() + private companion object { private const val TAG = "RxCodeStartup" } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt index 6f38a1f1..9d8b4ab3 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt @@ -18,7 +18,13 @@ import java.util.UUID /** Shared JSON config: Swift's default JSONEncoder/JSONDecoder behavior. */ val RxJson: Json = Json { ignoreUnknownKeys = true - encodeDefaults = false + // Swift's JSONEncoder always emits every stored property, and its JSONDecoder + // requires every non-optional one. kotlinx defaults to omitting a property when + // it equals its default value — which silently drops fields like + // `forceRefresh = false`, causing Swift to fail decoding with keyNotFound and + // the relay to drop the whole payload. Always encode defaults so the wire shape + // matches what Swift's non-optional Codable structs expect. + encodeDefaults = true explicitNulls = false isLenient = true coerceInputValues = true diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index fcf362a7..d0b5e03b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -144,14 +144,28 @@ class MobileAppState @Inject constructor( }.collect { (paired, activeId, relay) -> val active = paired .firstOrNull { it.id == activeId } - ?.pubkeyHex - ?: paired.firstOrNull()?.pubkeyHex - ?: "" - val resolvedRelay = (relay ?: paired.firstOrNull()?.relayUrl ?: defaultRelayUrl()) + ?: paired.firstOrNull() + val activeHex = active?.pubkeyHex ?: "" + val activeCompositeId = active?.id ?: "" + val resolvedRelay = (relay ?: active?.relayUrl ?: paired.firstOrNull()?.relayUrl ?: defaultRelayUrl()) // Sync peers + relay with the new persisted snapshot. paired.forEach { client.addPeer(it.pubkeyHex) } if (resolvedRelay != _state.value.relayUrl) client.setRelayUrl(resolvedRelay) - _state.update { it.copy(pairedDesktops = paired, activeDesktopPubkey = active, relayUrl = resolvedRelay) } + _state.update { + val desktopChanged = it.activeDesktopId != activeCompositeId || + it.activeDesktopPubkey != activeHex + it.copy( + pairedDesktops = paired, + activeDesktopId = activeCompositeId, + activeDesktopPubkey = activeHex, + relayUrl = resolvedRelay, + hasReceivedInitialSnapshot = if (desktopChanged) false else it.hasReceivedInitialSnapshot, + acpRegistryLoading = if (desktopChanged) false else it.acpRegistryLoading, + skillCatalogLoading = if (desktopChanged) false else it.skillCatalogLoading, + acpRegistryError = if (desktopChanged) null else it.acpRegistryError, + skillCatalogError = if (desktopChanged) null else it.skillCatalogError, + ) + } } } @@ -225,7 +239,19 @@ class MobileAppState @Inject constructor( applyMCPMutationResult(payload.data) } is Payload.ACPRegistryResult -> { - if (!isActiveDesktop(fromHex)) return + if (!isActiveDesktop(fromHex)) { + Log.w( + TAG, + "acp registry result ignored inactive from=${fromHex.take(12)} " + + "active=${_state.value.activeDesktopPubkey.take(12)} id=${payload.data.clientRequestID}", + ) + return + } + Log.w( + TAG, + "acp registry result inbound id=${payload.data.clientRequestID} ok=${payload.data.ok} " + + "agents=${payload.data.registryAgents.size} clients=${payload.data.installedClients.size}", + ) applyACPRegistryResult(payload.data) } is Payload.ACPMutationResult -> { @@ -233,7 +259,19 @@ class MobileAppState @Inject constructor( applyACPMutationResult(payload.data) } is Payload.SkillCatalogResult -> { - if (!isActiveDesktop(fromHex)) return + if (!isActiveDesktop(fromHex)) { + Log.w( + TAG, + "skill catalog result ignored inactive from=${fromHex.take(12)} " + + "active=${_state.value.activeDesktopPubkey.take(12)} id=${payload.data.clientRequestID}", + ) + return + } + Log.w( + TAG, + "skill catalog result inbound id=${payload.data.clientRequestID} ok=${payload.data.ok} " + + "plugins=${payload.data.plugins.size} sources=${payload.data.sources.size}", + ) applySkillCatalogResult(payload.data) } is Payload.SkillMutationResult -> { @@ -1033,25 +1071,47 @@ class MobileAppState @Inject constructor( // ---- ACP agent clients ---- - fun requestACPRegistry(forceRefresh: Boolean = false) { + fun requestACPRegistry(forceRefresh: Boolean = false, retryOnTimeout: Boolean = true) { val hex = _state.value.activeDesktopPubkey if (hex.isEmpty()) { + Log.w(TAG, "acp registry request skipped: no active desktop") + pendingACPRegistryRequestID = null _state.update { it.copy(acpRegistryError = "Connect a Mac to manage agents.") } return } + if (!_state.value.hasReceivedInitialSnapshot) { + Log.w(TAG, "acp registry request skipped: waiting for initial snapshot") + pendingACPRegistryRequestID = null + _state.update { it.copy(acpRegistryLoading = false, acpRegistryError = null) } + return + } val payload = app.rxlab.rxcode.proto.ACPRegistryRequestPayload(forceRefresh = forceRefresh) pendingACPRegistryRequestID = payload.clientRequestID + Log.w( + TAG, + "acp registry request start id=${payload.clientRequestID} forceRefresh=$forceRefresh " + + "desktop=${hex.take(12)} connection=${_state.value.connectionState} " + + "agents=${_state.value.acpRegistryAgents.size} clients=${_state.value.acpInstalledClients.size}", + ) _state.update { it.copy(acpRegistryLoading = true, acpRegistryError = null) } viewModelScope.launch { val sent = client.send(Payload.ACPRegistryRequest(payload), hex) + Log.w(TAG, "acp registry request send id=${payload.clientRequestID} sent=$sent desktop=${hex.take(12)}") if (!sent) { if (pendingACPRegistryRequestID == payload.clientRequestID) pendingACPRegistryRequestID = null + Log.w(TAG, "acp registry request failed to send id=${payload.clientRequestID}") _state.update { it.copy(acpRegistryLoading = false, acpRegistryError = "Failed to request agents.") } return@launch } delay(REMOTE_CONFIG_TIMEOUT_MS) if (pendingACPRegistryRequestID == payload.clientRequestID) { pendingACPRegistryRequestID = null + Log.w(TAG, "acp registry request timed out id=${payload.clientRequestID}") + if (retryOnTimeout) { + Log.w(TAG, "acp registry request retrying once after timeout id=${payload.clientRequestID}") + requestACPRegistry(forceRefresh = forceRefresh, retryOnTimeout = false) + return@launch + } _state.update { it.copy(acpRegistryLoading = false, acpRegistryError = REMOTE_TIMEOUT_MESSAGE) } } } @@ -1130,8 +1190,21 @@ class MobileAppState @Inject constructor( } private fun applyACPRegistryResult(result: app.rxlab.rxcode.proto.ACPRegistryResultPayload) { - if (pendingACPRegistryRequestID != result.clientRequestID) return + val pending = pendingACPRegistryRequestID + if (pending != result.clientRequestID) { + Log.w( + TAG, + "acp registry result dropped id=${result.clientRequestID} pending=$pending " + + "ok=${result.ok} agents=${result.registryAgents.size} clients=${result.installedClients.size}", + ) + return + } pendingACPRegistryRequestID = null + Log.w( + TAG, + "acp registry result applied id=${result.clientRequestID} ok=${result.ok} " + + "agents=${result.registryAgents.size} clients=${result.installedClients.size}", + ) _state.update { it.copy( acpRegistryLoading = false, @@ -1156,25 +1229,47 @@ class MobileAppState @Inject constructor( // ---- Skills marketplace ---- - fun requestSkillCatalog(forceRefresh: Boolean = false) { + fun requestSkillCatalog(forceRefresh: Boolean = false, retryOnTimeout: Boolean = true) { val hex = _state.value.activeDesktopPubkey if (hex.isEmpty()) { + Log.w(TAG, "skill catalog request skipped: no active desktop") + pendingSkillCatalogRequestID = null _state.update { it.copy(skillCatalogError = "Connect a Mac to browse skills.") } return } + if (!_state.value.hasReceivedInitialSnapshot) { + Log.w(TAG, "skill catalog request skipped: waiting for initial snapshot") + pendingSkillCatalogRequestID = null + _state.update { it.copy(skillCatalogLoading = false, skillCatalogError = null) } + return + } val payload = app.rxlab.rxcode.proto.SkillCatalogRequestPayload(forceRefresh = forceRefresh) pendingSkillCatalogRequestID = payload.clientRequestID + Log.w( + TAG, + "skill catalog request start id=${payload.clientRequestID} forceRefresh=$forceRefresh " + + "desktop=${hex.take(12)} connection=${_state.value.connectionState} " + + "plugins=${_state.value.skillCatalog.size} sources=${_state.value.skillSources.size}", + ) _state.update { it.copy(skillCatalogLoading = true, skillCatalogError = null) } viewModelScope.launch { val sent = client.send(Payload.SkillCatalogRequest(payload), hex) + Log.w(TAG, "skill catalog request send id=${payload.clientRequestID} sent=$sent desktop=${hex.take(12)}") if (!sent) { if (pendingSkillCatalogRequestID == payload.clientRequestID) pendingSkillCatalogRequestID = null + Log.w(TAG, "skill catalog request failed to send id=${payload.clientRequestID}") _state.update { it.copy(skillCatalogLoading = false, skillCatalogError = "Failed to request skills.") } return@launch } delay(REMOTE_CONFIG_TIMEOUT_MS) if (pendingSkillCatalogRequestID == payload.clientRequestID) { pendingSkillCatalogRequestID = null + Log.w(TAG, "skill catalog request timed out id=${payload.clientRequestID}") + if (retryOnTimeout) { + Log.w(TAG, "skill catalog request retrying once after timeout id=${payload.clientRequestID}") + requestSkillCatalog(forceRefresh = forceRefresh, retryOnTimeout = false) + return@launch + } _state.update { it.copy(skillCatalogLoading = false, skillCatalogError = REMOTE_TIMEOUT_MESSAGE) } } } @@ -1294,8 +1389,21 @@ class MobileAppState @Inject constructor( } private fun applySkillCatalogResult(result: app.rxlab.rxcode.proto.SkillCatalogResultPayload) { - if (pendingSkillCatalogRequestID != result.clientRequestID) return + val pending = pendingSkillCatalogRequestID + if (pending != result.clientRequestID) { + Log.w( + TAG, + "skill catalog result dropped id=${result.clientRequestID} pending=$pending " + + "ok=${result.ok} plugins=${result.plugins.size} sources=${result.sources.size}", + ) + return + } pendingSkillCatalogRequestID = null + Log.w( + TAG, + "skill catalog result applied id=${result.clientRequestID} ok=${result.ok} " + + "plugins=${result.plugins.size} sources=${result.sources.size}", + ) _state.update { it.copy( skillCatalogLoading = false, @@ -1350,7 +1458,18 @@ class MobileAppState @Inject constructor( _state.update { it.copy(pairing = PairingStatus.Failed("The pairing QR code has expired. Generate a fresh one on your Mac.")) } return } - _state.update { it.copy(pairing = PairingStatus.InProgress) } + pendingACPRegistryRequestID = null + pendingSkillCatalogRequestID = null + _state.update { + it.copy( + pairing = PairingStatus.InProgress, + hasReceivedInitialSnapshot = false, + acpRegistryLoading = false, + skillCatalogLoading = false, + acpRegistryError = null, + skillCatalogError = null, + ) + } viewModelScope.launch { try { Log.w(TAG, "pairing relay setup: relay=${token.relayURL} desktop=${token.desktopPubkeyHex.take(12)}") diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt index 9c57a7a8..1af3d3ed 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt @@ -31,6 +31,7 @@ import java.util.UUID */ data class MobileState( val pairedDesktops: List = emptyList(), + val activeDesktopId: String = "", val activeDesktopPubkey: String = "", val relayUrl: String = "", val connectionState: RelayClient.ConnectionState = RelayClient.ConnectionState.DISCONNECTED, @@ -133,7 +134,8 @@ data class MobileState( val isPaired: Boolean get() = activeDesktopPubkey.isNotEmpty() val activeDesktop: PairedDesktop? - get() = pairedDesktops.firstOrNull { it.pubkeyHex == activeDesktopPubkey } + get() = pairedDesktops.firstOrNull { it.id == activeDesktopId } + ?: pairedDesktops.firstOrNull { it.pubkeyHex == activeDesktopPubkey } } sealed interface PairingStatus { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt index ba7d770b..8cc5a685 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt @@ -1,5 +1,6 @@ package app.rxlab.rxcode.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons @@ -12,21 +13,27 @@ import androidx.compose.material3.Text import androidx.compose.ui.Modifier import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.state.PairingStatus import app.rxlab.rxcode.ui.briefing.BriefingPaneScreen import app.rxlab.rxcode.ui.onboarding.OnboardingScreen import app.rxlab.rxcode.ui.projects.ProjectsPaneScreen import app.rxlab.rxcode.ui.search.SearchScreen import app.rxlab.rxcode.ui.settings.SettingsScreen import app.rxlab.rxcode.ui.sync.SyncLoadingView -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.delay +private const val MaxRootTabHistory = 8 + /** * Root composable. Adapts top-level navigation to window class via * [NavigationSuiteScaffold]: bottom NavigationBar on a phone, NavigationRail @@ -49,10 +56,6 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { // ~15 seconds without a snapshot we flip into the timed-out state so the // user can retry, switch desktops, or pair a new Mac. var showPairingFromSplash by rememberSaveable { mutableStateOf(false) } - if (showPairingFromSplash) { - OnboardingScreen(state = state, viewModel = viewModel) - return - } if (!state.hasReceivedInitialSnapshot) { var timedOut by rememberSaveable { mutableStateOf(false) } LaunchedEffect(state.hasReceivedInitialSnapshot, state.activeDesktopPubkey) { @@ -64,7 +67,7 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { SyncLoadingView( isTimedOut = timedOut, pairedDesktops = state.pairedDesktops, - activeDesktopPubkey = state.activeDesktopPubkey, + activeDesktopId = state.activeDesktopId, onRetry = { timedOut = false viewModel.requestSnapshot("user_retry") @@ -75,11 +78,36 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { }, onPairNewDesktop = { showPairingFromSplash = true }, ) + if (showPairingFromSplash) { + PairingDialog( + state = state, + viewModel = viewModel, + onDismiss = { showPairingFromSplash = false }, + ) + } return } - var currentTab by rememberSaveable { mutableStateOf(RootTab.Briefing.name) } - val tab = RootTab.valueOf(currentTab) + var rootTabHistory by rememberSaveable { mutableStateOf(listOf(RootTab.Briefing.name)) } + val tab = RootTab.valueOf(rootTabHistory.lastOrNull() ?: RootTab.Briefing.name) + val tabStateHolder = rememberSaveableStateHolder() + + fun navigateToTab(destination: RootTab) { + if (tab == destination) return + rootTabHistory = (rootTabHistory + destination.name).takeLast(MaxRootTabHistory) + } + + fun navigateRootBack() { + rootTabHistory = if (rootTabHistory.size > 1) { + rootTabHistory.dropLast(1) + } else { + listOf(RootTab.Briefing.name) + } + } + + BackHandler(enabled = rootTabHistory.size > 1) { + navigateRootBack() + } // Global search is presented as a full-screen overlay rather than a tab — // every list/detail screen surfaces a toolbar search button that flips this @@ -94,7 +122,7 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { LaunchedEffect(state.pendingNotificationSessionID) { val sid = state.pendingNotificationSessionID ?: return@LaunchedEffect viewModel.selectSession(sid) - currentTab = RootTab.Projects.name + navigateToTab(RootTab.Projects) viewModel.consumePendingNotificationDeepLink() } @@ -104,43 +132,45 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { RootTab.allTabs.forEach { t -> item( selected = t == tab, - onClick = { currentTab = t.name }, + onClick = { navigateToTab(t) }, icon = { Icon(t.icon, contentDescription = null) }, label = { Text(t.label) }, ) } } ) { - when (tab) { - RootTab.Briefing -> BriefingPaneScreen( - state = state, - viewModel = viewModel, - onOpenSession = { sid -> - // ProjectsPaneScreen reacts to `state.activeSessionID` and - // pushes the chat detail itself, so we just have to switch - // tabs after selecting the session. - viewModel.selectSession(sid) - currentTab = RootTab.Projects.name - }, - onNewThread = { pid -> - viewModel.startNewSession(pid, planMode = false) - currentTab = RootTab.Projects.name - }, - onOpenSearch = openSearch, - ) - RootTab.Projects -> ProjectsPaneScreen( - state = state, - viewModel = viewModel, - onSettingsClick = { currentTab = RootTab.Settings.name }, - onOpenSearch = openSearch, - ) - RootTab.Settings -> SettingsScreen( - state = state, - viewModel = viewModel, - onBack = { currentTab = RootTab.Briefing.name }, - onPairNewMac = { showPairingFromSplash = true }, - onOpenSearch = openSearch, - ) + tabStateHolder.SaveableStateProvider(tab.name) { + when (tab) { + RootTab.Briefing -> BriefingPaneScreen( + state = state, + viewModel = viewModel, + onOpenSession = { sid -> + // ProjectsPaneScreen reacts to `state.activeSessionID` and + // pushes the chat detail itself, so we just have to switch + // tabs after selecting the session. + viewModel.selectSession(sid) + navigateToTab(RootTab.Projects) + }, + onNewThread = { pid -> + viewModel.startNewSession(pid, planMode = false) + navigateToTab(RootTab.Projects) + }, + onOpenSearch = openSearch, + ) + RootTab.Projects -> ProjectsPaneScreen( + state = state, + viewModel = viewModel, + onSettingsClick = { navigateToTab(RootTab.Settings) }, + onOpenSearch = openSearch, + ) + RootTab.Settings -> SettingsScreen( + state = state, + viewModel = viewModel, + onBack = { navigateRootBack() }, + onPairNewMac = { showPairingFromSplash = true }, + onOpenSearch = openSearch, + ) + } } } @@ -151,7 +181,7 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { viewModel = viewModel, onOpenSession = { sid -> viewModel.selectSession(sid) - currentTab = RootTab.Projects.name + navigateToTab(RootTab.Projects) showSearch = false }, onClose = { @@ -161,6 +191,46 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { ) } } + + if (showPairingFromSplash) { + PairingDialog( + state = state, + viewModel = viewModel, + onDismiss = { showPairingFromSplash = false }, + ) + } + } +} + +@Composable +private fun PairingDialog( + state: MobileState, + viewModel: MobileAppState, + onDismiss: () -> Unit, +) { + var sawPairingInProgress by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.pairing) { + if (state.pairing == PairingStatus.InProgress) { + sawPairingInProgress = true + } else if (sawPairingInProgress && state.pairing == PairingStatus.Idle) { + onDismiss() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Surface(Modifier.fillMaxSize()) { + OnboardingScreen( + state = state, + viewModel = viewModel, + onDismiss = onDismiss, + ) + } } } @@ -176,4 +246,3 @@ enum class RootTab( val allTabs: List = listOf(Briefing, Projects, Settings) } } - diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/acp/AcpClientsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/acp/AcpClientsScreen.kt index 673ec0fa..7ac385c7 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/acp/AcpClientsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/acp/AcpClientsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.MobileACPClient @@ -63,8 +64,13 @@ fun AcpClientsScreen( val state by app.state.collectAsState() var pendingUninstall by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - if (state.acpRegistryAgents.isEmpty() && state.acpInstalledClients.isEmpty()) { + LaunchedEffect(online, state.activeDesktopPubkey, state.hasReceivedInitialSnapshot) { + if (online && + state.hasReceivedInitialSnapshot && + !state.acpRegistryLoading && + state.acpRegistryAgents.isEmpty() && + state.acpInstalledClients.isEmpty() + ) { app.requestACPRegistry() } } @@ -91,6 +97,18 @@ fun AcpClientsScreen( state.acpRegistryError?.let { item { AutopilotErrorRow(it) } } state.lastACPError?.let { item { AutopilotErrorRow(it) } } + if (online && !state.hasReceivedInitialSnapshot) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + Text("Waiting for Mac sync…", style = MaterialTheme.typography.bodyMedium) + } + } + } + if (state.acpInstalledClients.isNotEmpty()) { item { AutopilotSectionLabel("Installed") } items(state.acpInstalledClients, key = { it.id }) { client -> @@ -114,7 +132,8 @@ fun AcpClientsScreen( onInstall = { app.installACPAgent(agent.id) }, ) } - } else if (state.acpInstalledClients.isEmpty() && + } else if (state.hasReceivedInitialSnapshot && + state.acpInstalledClients.isEmpty() && !state.acpRegistryLoading && state.acpRegistryError == null ) { @@ -143,17 +162,21 @@ fun AcpClientsScreen( @Composable private fun AgentIcon(iconURL: String?) { + // Tint the logo to match the title text color (1:1 with iOS template-tinting), + // so monochrome ACP SVG logos render in the same color as the agent name. + val titleColor = MaterialTheme.colorScheme.onSurface if (iconURL.isNullOrEmpty()) { Icon( Icons.Outlined.Memory, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = titleColor, modifier = Modifier.size(28.dp), ) } else { AsyncImage( model = iconURL, contentDescription = null, + colorFilter = ColorFilter.tint(titleColor), modifier = Modifier .size(28.dp) .clip(RoundedCornerShape(6.dp)), diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt index 5ff96d18..439fa812 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt @@ -4,12 +4,15 @@ import android.content.Context import android.net.Uri import android.os.Build import android.util.Log +import android.util.Size import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.background @@ -82,15 +85,20 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import java.util.concurrent.Executors /** Pair-with-Mac screen: device name, camera scanner, and photo QR fallback. */ -@OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable -fun OnboardingScreen(state: MobileState, viewModel: MobileAppState) { +fun OnboardingScreen( + state: MobileState, + viewModel: MobileAppState, + onDismiss: (() -> Unit)? = null, +) { val context = LocalContext.current var scannerOpen by remember { mutableStateOf(false) } var scanOptionsOpen by remember { mutableStateOf(false) } @@ -156,6 +164,18 @@ fun OnboardingScreen(state: MobileState, viewModel: MobileAppState) { Scaffold( containerColor = MaterialTheme.colorScheme.surface, + topBar = { + if (onDismiss != null) { + CenterAlignedTopAppBar( + title = { Text("Pair with your Mac") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + } + }, ) { padding -> Column( modifier = Modifier @@ -402,9 +422,6 @@ private fun PairingCameraScreen( var scanLocked by remember { mutableStateOf(false) } var scannerKey by remember { mutableStateOf(0) } var scanError by remember { mutableStateOf(null) } - // Manual scanning: the camera previews continuously, but barcode analysis - // only runs after the user taps "Scan" (not automatically on every frame). - var scanArmed by remember { mutableStateOf(false) } LaunchedEffect(camPermission.status.isGranted) { pairingLog("camera permission granted=${camPermission.status.isGranted}") @@ -429,24 +446,22 @@ private fun PairingCameraScreen( .padding(padding) .background(Color.Black), ) { + val scannerActive = camPermission.status.isGranted && !scanLocked && pairing !is PairingStatus.InProgress if (camPermission.status.isGranted) { QRCameraPreview( modifier = Modifier.fillMaxSize(), scanKey = scannerKey, - enabled = scanArmed && !scanLocked && pairing !is PairingStatus.InProgress, + enabled = scannerActive, onToken = { token -> pairingLog("camera QR accepted: ${token.logSummary()}") scanLocked = true - scanArmed = false scanError = null onToken(token) }, onInvalidQr = { if (!scanLocked) { Log.w(TAG, "camera QR rejected: not an RxCode pairing token") - // Stop scanning and let the user reposition and tap again. - scanArmed = false - scanError = "That QR code is not an RxCode pairing code. Tap Scan to try again." + scanError = "That QR code is not an RxCode pairing code." } }, ) @@ -483,18 +498,12 @@ private fun PairingCameraScreen( PairingCameraStatus( pairing = pairing, scanError = scanError, - scanArmed = scanArmed, - onScan = { - pairingLog("user armed camera QR scan") - scanError = null - scanArmed = true - }, + scanning = scannerActive, onRetry = { pairingLog("retrying camera QR scan") onRetry() scanError = null scanLocked = false - scanArmed = false scannerKey += 1 }, modifier = Modifier @@ -525,8 +534,7 @@ private fun CameraShade() { private fun PairingCameraStatus( pairing: PairingStatus, scanError: String?, - scanArmed: Boolean, - onScan: () -> Unit, + scanning: Boolean, onRetry: () -> Unit, modifier: Modifier = Modifier, ) { @@ -539,21 +547,22 @@ private fun PairingCameraStatus( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { when (pairing) { PairingStatus.Idle -> { - if (scanArmed) { + if (scanning) { Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(10.dp)) Text("Scanning…", style = MaterialTheme.typography.titleMedium) } + Text( + "Position the QR code inside the frame.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } else { - Text("Position the QR code inside the frame, then tap Scan.", style = MaterialTheme.typography.titleMedium) + Text("Position the QR code inside the frame.", style = MaterialTheme.typography.titleMedium) scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Button(onClick = onScan, modifier = Modifier.fillMaxWidth()) { - Icon(Icons.Outlined.QrCodeScanner, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Scan") - } } } PairingStatus.InProgress -> { @@ -641,9 +650,21 @@ private fun bindCamera( val preview = Preview.Builder().build().apply { setSurfaceProvider(previewView.surfaceProvider) } - scanner = BarcodeScanning.getClient() - pairingLog("ML Kit barcode scanner created") + val scannerOptions = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + scanner = BarcodeScanning.getClient(scannerOptions) + pairingLog("ML Kit QR scanner created") + val analysisResolution = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1280, 720), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + ) + .build() val analysis = ImageAnalysis.Builder() + .setResolutionSelector(analysisResolution) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() var lastSeen: String? = null @@ -679,7 +700,7 @@ private fun bindCamera( activeScanner.process(input) .addOnSuccessListener(mainExecutor) { barcodes -> val qrValues = barcodes - .filter { it.format == Barcode.FORMAT_QR_CODE || it.rawValue != null } + .filter { it.format == Barcode.FORMAT_QR_CODE } .mapNotNull { it.rawValue } if (qrValues.isEmpty()) { if (analyzedFrames % 90 == 0) { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt index be6a8878..0648343d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package app.rxlab.rxcode.ui.settings +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -99,6 +100,7 @@ fun SettingsScreen( state.connectionState == RelayClient.ConnectionState.CONNECTED if (showAutopilot) { + BackHandler { showAutopilot = false } app.rxlab.rxcode.ui.autopilot.AutopilotNavHost( app = viewModel, online = online, @@ -107,6 +109,7 @@ fun SettingsScreen( return } if (showSkills) { + BackHandler { showSkills = false } app.rxlab.rxcode.ui.skills.SkillsMarketScreen( app = viewModel, online = online, @@ -115,6 +118,7 @@ fun SettingsScreen( return } if (showAcpClients) { + BackHandler { showAcpClients = false } app.rxlab.rxcode.ui.acp.AcpClientsScreen( app = viewModel, online = online, @@ -123,6 +127,7 @@ fun SettingsScreen( return } if (showMcpServers) { + BackHandler { showMcpServers = false } app.rxlab.rxcode.ui.mcp.McpServersScreen( app = viewModel, online = online, @@ -163,7 +168,7 @@ fun SettingsScreen( items(state.pairedDesktops, key = { it.id }) { desktop -> PairedDesktopCard( desktop = desktop, - isActive = desktop.pubkeyHex == state.activeDesktopPubkey, + isActive = desktop.id == state.activeDesktopId, onSwitch = { haptics.play(HapticEvent.Selection) viewModel.switchActiveDesktop(desktop) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/skills/SkillsMarketScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/skills/SkillsMarketScreen.kt index efb5123f..747a1c46 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/skills/SkillsMarketScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/skills/SkillsMarketScreen.kt @@ -70,8 +70,14 @@ fun SkillsMarketScreen( var filterMenuOpen by remember { mutableStateOf(false) } var showGitSourceSheet by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (state.skillCatalog.isEmpty()) app.requestSkillCatalog() + LaunchedEffect(online, state.activeDesktopPubkey, state.hasReceivedInitialSnapshot) { + if (online && + state.hasReceivedInitialSnapshot && + !state.skillCatalogLoading && + state.skillCatalog.isEmpty() + ) { + app.requestSkillCatalog() + } } val availableMarketplaces = remember(state.skillCatalog) { @@ -134,7 +140,15 @@ fun SkillsMarketScreen( if (state.skillCatalog.isEmpty()) { item { - if (state.skillCatalogLoading) { + if (online && !state.hasReceivedInitialSnapshot) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + Text("Waiting for Mac sync…", style = MaterialTheme.typography.bodyMedium) + } + } else if (state.skillCatalogLoading) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sync/SyncLoadingView.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sync/SyncLoadingView.kt index 84d03e05..1b019bfa 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sync/SyncLoadingView.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sync/SyncLoadingView.kt @@ -56,7 +56,7 @@ import kotlinx.coroutines.delay fun SyncLoadingView( isTimedOut: Boolean, pairedDesktops: List, - activeDesktopPubkey: String, + activeDesktopId: String, onRetry: () -> Unit, onSelectDesktop: (PairedDesktop) -> Unit, onPairNewDesktop: () -> Unit, @@ -137,7 +137,7 @@ fun SyncLoadingView( items(pairedDesktops, key = { it.id }) { desktop -> DesktopRow( desktop = desktop, - isActive = desktop.pubkeyHex == activeDesktopPubkey, + isActive = desktop.id == activeDesktopId, onClick = { onSelectDesktop(desktop) }, ) } diff --git a/RxCodeAndroid/gradle/libs.versions.toml b/RxCodeAndroid/gradle/libs.versions.toml index 697ccb98..ac7a5bb2 100644 --- a/RxCodeAndroid/gradle/libs.versions.toml +++ b/RxCodeAndroid/gradle/libs.versions.toml @@ -77,6 +77,7 @@ mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning" accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 53f977be..0667bcde 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -3,6 +3,10 @@ import UserNotifications import CryptoKit import RxCodeSync import os.log +#if canImport(SDWebImageSVGCoder) +import SDWebImage +import SDWebImageSVGCoder +#endif /// Bridges UIKit's APNs registration into `MobileAppState`. The state object /// forwards the device token to the paired desktop over the encrypted relay. @@ -23,6 +27,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { FirebaseBootstrap.configure() + #if canImport(SDWebImageSVGCoder) + // ACP registry icons are monochrome SVGs; register the coder so + // SDWebImage can decode them for `MobileACPIconView`. + SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared) + #endif UNUserNotificationCenter.current().delegate = self logger.info("[APNs] notification delegate installed") Task { diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index a44116d6..d4fd0c3a 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -2557,6 +2557,12 @@ } } } + }, + "Creating environment…" : { + + }, + "Creating file…" : { + }, "Creating Pull Request…" : { "localizations" : { @@ -2931,6 +2937,12 @@ } } } + }, + "Deleting environment…" : { + + }, + "Deleting file…" : { + }, "Deny" : { "localizations" : { @@ -7870,6 +7882,9 @@ } } } + }, + "Saving file…" : { + }, "Scan" : { "localizations" : { diff --git a/RxCodeMobile/Views/MobileACPClientsView.swift b/RxCodeMobile/Views/MobileACPClientsView.swift index be1df58f..ff49e739 100644 --- a/RxCodeMobile/Views/MobileACPClientsView.swift +++ b/RxCodeMobile/Views/MobileACPClientsView.swift @@ -158,18 +158,7 @@ struct MobileACPClientsView: View { } private func agentIcon(_ urlString: String?) -> some View { - Group { - if let urlString, let url = URL(string: urlString) { - AsyncImage(url: url) { image in - image.resizable().scaledToFit() - } placeholder: { - Image(systemName: "cpu").foregroundStyle(.secondary) - } - } else { - Image(systemName: "cpu").foregroundStyle(.secondary) - } - } - .frame(width: 28, height: 28) + MobileACPIconView(url: urlString, size: 28) } private func errorRow(_ message: String) -> some View { diff --git a/RxCodeMobile/Views/MobileACPIconView.swift b/RxCodeMobile/Views/MobileACPIconView.swift new file mode 100644 index 00000000..eb746ccf --- /dev/null +++ b/RxCodeMobile/Views/MobileACPIconView.swift @@ -0,0 +1,65 @@ +import SwiftUI +#if canImport(SDWebImage) +import SDWebImage +#endif + +/// Renders an ACP client/agent icon from a remote URL on the iPhone, mirroring +/// the macOS `ACPIconView`. +/// +/// Registry icons are monochrome SVGs that use `fill="currentColor"`. iOS's +/// `UIImage` / SwiftUI `AsyncImage` can't decode raw SVG bytes, so we route the +/// load through `SDWebImageManager` with `SDImageSVGCoder` registered (see +/// `AppDelegate`). The decoded glyph is a black silhouette on a transparent +/// background, so we render it as a `.template` image tinted to the foreground +/// color — visible in both light and dark mode, just like the Mac. +/// +/// When the SDWebImage package isn't linked, this falls back to the `cpu` +/// SF Symbol so the project still builds and renders. +struct MobileACPIconView: View { + let url: String? + var size: CGFloat = 28 + + @State private var image: UIImage? + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .renderingMode(.template) + .interpolation(.high) + .scaledToFit() + .foregroundStyle(.primary) + } else { + Image(systemName: "cpu") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + } + } + .frame(width: size, height: size) + .task(id: url) { await loadIcon() } + } + + private func loadIcon() async { + image = nil + guard let url, let parsed = URL(string: url) else { return } + #if canImport(SDWebImage) + // Rasterize the vector at the on-screen pixel size so it stays crisp; + // the SVGs declare a tiny 16pt intrinsic size that would otherwise blur. + let pixels = size * UIScreen.main.scale + let loaded: UIImage? = await withCheckedContinuation { continuation in + SDWebImageManager.shared.loadImage( + with: parsed, + options: [.retryFailed], + context: [.imageThumbnailPixelSize: CGSize(width: pixels, height: pixels)], + progress: nil + ) { img, _, _, _, _, _ in + continuation.resume(returning: img) + } + } + guard !Task.isCancelled else { return } + image = loaded + #endif + } +}