From c57470753b93fe6b65227a946ff7de76953f25ed Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:25:21 +0800 Subject: [PATCH 1/4] feat(android): port iOS context menus + global search to Android One-to-one port of three recent iOS features to the Android app: - Briefing-detail toolbar context menu (autopilot + GitHub actions) - Session-list project toolbar menu (autopilot actions + Delete Project) - Global search tab (threads + published docs, All/Threads/Docs scopes) Adds PROJECT/SEARCH autopilot domains+ops, project/search DTOs, the delete_project payloads, project-level RPC methods, a shared ProjectActionsMenu, secrets-download + release-create sheets, and a SearchScreen with on-demand doc viewer. Reuses the existing autopilot RPC plumbing and matches the iOS wire format, so no desktop changes are needed. Search top bar respects the status-bar safe area. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/rxlab/rxcode/proto/AutopilotModels.kt | 64 ++ .../rxlab/rxcode/proto/AutopilotPayloads.kt | 55 ++ .../java/app/rxlab/rxcode/proto/Payload.kt | 28 + .../rxlab/rxcode/state/AutopilotService.kt | 85 +++ .../app/rxlab/rxcode/state/MobileAppState.kt | 136 +++++ .../app/rxlab/rxcode/state/MobileState.kt | 13 + .../java/app/rxlab/rxcode/ui/RxCodeApp.kt | 13 +- .../rxcode/ui/autopilot/ProjectActionsMenu.kt | 239 ++++++++ .../ui/briefing/BriefingDetailScreen.kt | 14 + .../rxlab/rxcode/ui/search/SearchScreen.kt | 321 ++++++++++ .../rxcode/ui/sessions/SessionsScreen.kt | 17 + .../rxcode/ui/settings/SettingsScreen.kt | 6 +- .../ui/sheets/ProjectAutopilotSheets.kt | 547 ++++++++++++++++++ 13 files changed, 1534 insertions(+), 4 deletions(-) create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt index ec7a8e7e..a2224f5b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotModels.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.longOrNull import java.time.Instant +import java.util.UUID /** * Domain models for the autopilot remote-management feature. Mirrors the @@ -475,3 +476,66 @@ data class ReleaseGithubSecretResult( val secretName: String, val repositoryFullName: String, ) + +// MARK: - Project context-menu results + +/** + * Per-project autopilot state powering the mobile context menu. Mirrors the + * desktop's hasSecrets / hasDocs / hasRelease checks so the phone picks the + * same menu items (Download vs Set Up, etc.). + */ +@Serializable +data class AutopilotProjectStatus( + val gitHubRepo: String? = null, + val hasSecrets: Boolean = false, + val hasDocs: Boolean = false, + val hasRelease: Boolean = false, +) + +/** Result of `projectCreatePullRequest`: the URL of the opened pull request. */ +@Serializable +data class AutopilotPullRequestResult(val url: String) + +/** Result of `projectSecretsWrite`: files written and any skipped conflicts. */ +@Serializable +data class AutopilotProjectSecretsDownloadResult( + val written: List = emptyList(), + val conflicts: List = emptyList(), +) + +// MARK: - Global search + +/** One thread match from the desktop's on-device index. */ +@Serializable +data class SearchHit( + val sessionID: String, + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val title: String, + val snippet: String, + @Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant, + val score: Float = 0f, +) + +/** One published-docs match from the rxlab docs service. */ +@Serializable +data class DocsSearchHit( + val documentId: String? = null, + val docId: String, + val docsRepositoryId: String? = null, + val repositoryFullName: String? = null, + val version: Int? = null, + val similarity: Double? = null, + val snippet: String? = null, + val originalLink: String? = null, +) { + val stableId: String get() = documentId ?: "${repositoryFullName ?: ""}:$docId" +} + +/** Combined search results for one query: thread hits + doc hits. */ +@Serializable +data class AutopilotSearchResult( + val query: String, + val projectIDs: List<@Serializable(with = UuidSerializer::class) UUID> = emptyList(), + val threadHits: List = emptyList(), + val docHits: List = emptyList(), +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt index 7c1941bc..70f04c7d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt @@ -28,6 +28,8 @@ enum class AutopilotDomain(val wire: String) { CI_UPDATES("ciUpdates"), DOCS("docs"), RELEASE("release"), + PROJECT("project"), + SEARCH("search"), } /** Every autopilot operation, globally unique so the desktop can switch on it. */ @@ -87,6 +89,23 @@ enum class AutopilotOp(val wire: String) { RELEASE_LIST_WORKFLOWS("releaseListWorkflows"), RELEASE_DISPATCH("releaseDispatch"), RELEASE_INSTALL_TOKEN("releaseInstallToken"), + + // Project context-menu actions — desktop-mediated so the Mac performs the + // exact work its own project/briefing context menu does. The `*Setup` ops + // ask the Mac to surface its setup chat/sheet; secrets download decrypts + // on-device then relays plaintext via `projectSecretsWrite`. + PROJECT_AUTOPILOT_STATUS("projectAutopilotStatus"), + PROJECT_SECRETS_SETUP("projectSecretsSetup"), + PROJECT_DOCS_SETUP("projectDocsSetup"), + PROJECT_DOCS_SEARCH("projectDocsSearch"), + PROJECT_RELEASE_SETUP("projectReleaseSetup"), + PROJECT_RELEASE_CREATE("projectReleaseCreate"), + PROJECT_SECRETS_DOWNLOAD("projectSecretsDownload"), + PROJECT_SECRETS_WRITE("projectSecretsWrite"), + PROJECT_CREATE_PULL_REQUEST("projectCreatePullRequest"), + + // Global search — one call returns thread matches AND published-docs matches. + SEARCH_THREADS_AND_DOCS("searchThreadsAndDocs"), } /** @@ -199,6 +218,42 @@ data class AutopilotReleaseDispatchBody(val repoId: String, val request: Release @Serializable data class AutopilotReleaseTokenBody(val repoId: String, val value: String) +// MARK: - Project context-menu bodies + +/** Addresses a project by id (status + the desktop-mediated setup actions). */ +@Serializable +data class AutopilotProjectBody( + @Serializable(with = UuidSerializer::class) val projectId: UUID, +) + +/** Addresses a project + branch. Used by `projectCreatePullRequest`. */ +@Serializable +data class AutopilotProjectBranchBody( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val branch: String, +) + +/** + * Already-decrypted secret files for `projectSecretsWrite`: the phone decrypts + * the chosen environment on-device, then relays plaintext over the E2E channel + * for the Mac to write into the project folder. + */ +@Serializable +data class AutopilotProjectSecretsWriteBody( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val files: List, + val overwrite: Boolean, +) { + @Serializable + data class Plaintext(val filename: String, val content: String) +} + +// MARK: - Global search body + +/** A single query run across both on-device threads and published docs. */ +@Serializable +data class AutopilotSearchBody(val query: String, val limit: Int? = null) + // MARK: - Result list wrappers @Serializable diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt index c35bf951..e7ff8ef9 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt @@ -97,6 +97,12 @@ sealed class Payload { data class BranchOpResult(val data: BranchOpResultPayload) : Payload() { override val type = "branch_op_result" } + data class DeleteProjectRequest(val data: DeleteProjectRequestPayload) : Payload() { + override val type = "delete_project_request" + } + data class DeleteProjectResult(val data: DeleteProjectResultPayload) : Payload() { + override val type = "delete_project_result" + } data class RunProfileMutationRequest(val data: RunProfileMutationRequestPayload) : Payload() { override val type = "run_profile_mutation_request" } @@ -443,6 +449,24 @@ data class ThreadChangesResultPayload( val uncommitted: List<SyncGitChange> = emptyList(), ) +/** + * Mobile → desktop: cascade-delete a project (sessions, search index, memory). + * The desktop confirms via [DeleteProjectResultPayload]. + */ +@Serializable +data class DeleteProjectRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectID: UUID, +) + +@Serializable +data class DeleteProjectResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val ok: Boolean, + val errorMessage: String? = null, +) + @Serializable data class PingPayload( @Serializable(with = SwiftDateSerializer::class) val t: Instant = Instant.now(), @@ -496,6 +520,8 @@ object PayloadSerializer : KSerializer<Payload> { "plan_decision" -> Payload.PlanDecision(json.decodeFromJsonElement(PlanDecisionPayload.serializer(), data)) "branch_op_request" -> Payload.BranchOpRequest(json.decodeFromJsonElement(BranchOpRequestPayload.serializer(), data)) "branch_op_result" -> Payload.BranchOpResult(json.decodeFromJsonElement(BranchOpResultPayload.serializer(), data)) + "delete_project_request" -> Payload.DeleteProjectRequest(json.decodeFromJsonElement(DeleteProjectRequestPayload.serializer(), data)) + "delete_project_result" -> Payload.DeleteProjectResult(json.decodeFromJsonElement(DeleteProjectResultPayload.serializer(), data)) "run_profile_mutation_request" -> Payload.RunProfileMutationRequest(json.decodeFromJsonElement(RunProfileMutationRequestPayload.serializer(), data)) "run_profile_result" -> Payload.RunProfileResult(json.decodeFromJsonElement(RunProfileResultPayload.serializer(), data)) "run_profile_run_request" -> Payload.RunProfileRunRequest(json.decodeFromJsonElement(RunProfileRunRequestPayload.serializer(), data)) @@ -537,6 +563,8 @@ object PayloadSerializer : KSerializer<Payload> { is Payload.PlanDecision -> value.type to json.encodeToJsonElement(PlanDecisionPayload.serializer(), value.data) is Payload.BranchOpRequest -> value.type to json.encodeToJsonElement(BranchOpRequestPayload.serializer(), value.data) is Payload.BranchOpResult -> value.type to json.encodeToJsonElement(BranchOpResultPayload.serializer(), value.data) + is Payload.DeleteProjectRequest -> value.type to json.encodeToJsonElement(DeleteProjectRequestPayload.serializer(), value.data) + is Payload.DeleteProjectResult -> value.type to json.encodeToJsonElement(DeleteProjectResultPayload.serializer(), value.data) is Payload.RunProfileMutationRequest -> value.type to json.encodeToJsonElement(RunProfileMutationRequestPayload.serializer(), value.data) is Payload.RunProfileResult -> value.type to json.encodeToJsonElement(RunProfileResultPayload.serializer(), value.data) is Payload.RunProfileRunRequest -> value.type to json.encodeToJsonElement(RunProfileRunRequestPayload.serializer(), value.data) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt index 655537d0..6937e915 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt @@ -15,6 +15,14 @@ import app.rxlab.rxcode.proto.AutopilotDocsUploadBody import app.rxlab.rxcode.proto.AutopilotDomain import app.rxlab.rxcode.proto.AutopilotIDBody import app.rxlab.rxcode.proto.AutopilotOp +import app.rxlab.rxcode.proto.AutopilotProjectBody +import app.rxlab.rxcode.proto.AutopilotProjectBranchBody +import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult +import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody +import app.rxlab.rxcode.proto.AutopilotProjectStatus +import app.rxlab.rxcode.proto.AutopilotPullRequestResult +import app.rxlab.rxcode.proto.AutopilotSearchBody +import app.rxlab.rxcode.proto.AutopilotSearchResult import app.rxlab.rxcode.proto.AutopilotReleaseDispatchBody import app.rxlab.rxcode.proto.AutopilotReleaseTokenBody import app.rxlab.rxcode.proto.AutopilotReposQuery @@ -408,6 +416,83 @@ class AutopilotService( ) ) + // MARK: - Project context-menu actions (desktop-mediated, 1:1 with macOS) + + /** Per-project autopilot state deciding which context-menu items to show. */ + suspend fun projectAutopilotStatus(projectId: UUID): AutopilotProjectStatus = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_AUTOPILOT_STATUS, + encodeBody(AutopilotProjectBody(projectId)), + ) + ) + + /** Ask the Mac to surface the secrets-setup flow for the project. */ + suspend fun requestProjectSecretsSetup(projectId: UUID) { + rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_SECRETS_SETUP, encodeBody(AutopilotProjectBody(projectId))) + } + + /** Ask the Mac to start the docs-setup chat for the project. */ + suspend fun requestProjectDocsSetup(projectId: UUID) { + rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_DOCS_SETUP, encodeBody(AutopilotProjectBody(projectId))) + } + + /** Ask the Mac to open its docs search overlay. */ + suspend fun requestProjectDocsSearch(projectId: UUID) { + rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_DOCS_SEARCH, encodeBody(AutopilotProjectBody(projectId))) + } + + /** Ask the Mac to start the release-setup chat for the project. */ + suspend fun requestProjectReleaseSetup(projectId: UUID) { + rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_RELEASE_SETUP, encodeBody(AutopilotProjectBody(projectId))) + } + + /** Ask the Mac to present its create-release sheet for the project. */ + suspend fun requestProjectReleaseCreate(projectId: UUID) { + rawCall(AutopilotDomain.PROJECT, AutopilotOp.PROJECT_RELEASE_CREATE, encodeBody(AutopilotProjectBody(projectId))) + } + + /** + * Ask the Mac to open a pull request for the project's branch (it pushes the + * branch, drafts the title/body from the briefing, and opens the PR). Returns + * the PR URL so the phone can open it. + */ + suspend fun requestProjectCreatePullRequest(projectId: UUID, branch: String): String = + decodeResult<AutopilotPullRequestResult>( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_CREATE_PULL_REQUEST, + encodeBody(AutopilotProjectBranchBody(projectId, branch)), + ) + ).url + + /** + * Relay already-decrypted secret files for the Mac to write into the project + * folder. Decryption happens on-device first (see [SecretsManager]); this only + * carries plaintext over the E2E channel. Returns files written + conflicts. + */ + suspend fun projectSecretsWrite( + projectId: UUID, + files: List<AutopilotProjectSecretsWriteBody.Plaintext>, + overwrite: Boolean, + ): AutopilotProjectSecretsDownloadResult = + decodeResult( + rawCall( + AutopilotDomain.PROJECT, AutopilotOp.PROJECT_SECRETS_WRITE, + encodeBody(AutopilotProjectSecretsWriteBody(projectId, files, overwrite)), + ) + ) + + // MARK: - Global search + + /** One round trip returning thread matches AND published-docs matches. */ + suspend fun searchThreadsAndDocs(query: String, limit: Int? = null): AutopilotSearchResult = + decodeResult( + rawCall( + AutopilotDomain.SEARCH, AutopilotOp.SEARCH_THREADS_AND_DOCS, + encodeBody(AutopilotSearchBody(query, limit)), + ) + ) + companion object { /** Longer ceiling than config calls: scans/secret minting take a while. */ private const val AUTOPILOT_TIMEOUT_MS = 45_000L 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 386bca8f..e2ef661b 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 @@ -1,12 +1,17 @@ package app.rxlab.rxcode.state +import android.content.Context import android.os.Build import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.rxlab.rxcode.BuildConfig +import app.rxlab.rxcode.proto.AutopilotProjectSecretsDownloadResult +import app.rxlab.rxcode.proto.AutopilotProjectSecretsWriteBody +import app.rxlab.rxcode.proto.AutopilotProjectStatus import app.rxlab.rxcode.proto.BranchOpRequestPayload import app.rxlab.rxcode.proto.CancelStreamPayload +import app.rxlab.rxcode.proto.DeleteProjectRequestPayload import app.rxlab.rxcode.proto.LoadMoreMessagesRequestPayload import app.rxlab.rxcode.proto.MobileRunTaskSnapshot import app.rxlab.rxcode.proto.NewSessionRequestPayload @@ -69,6 +74,8 @@ class MobileAppState @Inject constructor( private var started = false private var pairingTimeout: Job? = null private var pendingThreadChangesId: UUID? = null + private var pendingDeleteProjectId: UUID? = null + private var searchJob: Job? = null /** * Autopilot remote-management round-trips (mirrors the desktop's Autopilot @@ -182,6 +189,17 @@ class MobileAppState @Inject constructor( if (payload.data.ok) requestSnapshot("branch_op_${payload.data.operation.name}") else Log.w(TAG, "branch op ${payload.data.operation.name} failed: ${payload.data.errorMessage}") } + is Payload.DeleteProjectResult -> { + if (!isActiveDesktop(fromHex)) return + if (payload.data.clientRequestID != pendingDeleteProjectId) return + pendingDeleteProjectId = null + if (!payload.data.ok) { + Log.w(TAG, "delete project failed: ${payload.data.errorMessage}") + // Restore the optimistically-removed project from the desktop. + _state.update { it.copy(lastError = payload.data.errorMessage ?: "Couldn't delete the project.") } + requestSnapshot("delete_project_failed") + } + } is Payload.RunProfileResult -> handleRunProfileResult(fromHex, payload.data) is Payload.RunTaskUpdate -> handleRunTaskUpdate(fromHex, payload.data.task) is Payload.ThreadChangesResult -> handleThreadChangesResult(fromHex, payload.data) @@ -571,6 +589,122 @@ class MobileAppState @Inject constructor( } } + // MARK: - Project context-menu actions + + /** + * Cascade-delete a project on the desktop. Optimistically drops it (and its + * sessions) from local state so the UI updates immediately; the desktop + * confirms via `delete_project_result`. On failure [handleInbound] re-syncs + * to restore the project. + */ + fun deleteProject(projectId: UUID) { + if (!_state.value.isPaired) return + val request = DeleteProjectRequestPayload(projectID = projectId) + pendingDeleteProjectId = request.clientRequestID + _state.update { + it.copy( + projects = it.projects.filterNot { p -> p.id == projectId }, + sessions = it.sessions.filterNot { s -> s.projectId == projectId }, + ) + } + viewModelScope.launch { + val sent = client.send(Payload.DeleteProjectRequest(request), _state.value.activeDesktopPubkey) + if (!sent) { + pendingDeleteProjectId = null + _state.update { it.copy(lastError = "Couldn't reach your Mac to delete the project.") } + requestSnapshot("delete_project_send_failed") + } + } + } + + /** + * Per-project autopilot state (has secrets / docs / release), used by the + * project context menu to choose the same items the desktop menu shows. + */ + suspend fun projectAutopilotStatus(projectId: UUID): AutopilotProjectStatus = + autopilot.projectAutopilotStatus(projectId) + + /** Open a PR for the project's branch on the Mac; returns the PR URL. */ + suspend fun requestProjectCreatePullRequest(projectId: UUID, branch: String): String = + autopilot.requestProjectCreatePullRequest(projectId, branch) + + /** + * Download a secret environment into the project folder: decrypt on-device + * with the passkey-derived KEK, then relay the plaintext for the Mac to write. + * Mirrors iOS `downloadProjectSecrets`. Returns files written + conflicts. + */ + suspend fun downloadProjectSecrets( + context: Context, + projectId: UUID, + repo: String, + envId: String, + overwrite: Boolean, + ): AutopilotProjectSecretsDownloadResult { + val plaintext = secrets.environmentPlaintext(context, repo, envId) + val files = plaintext.map { AutopilotProjectSecretsWriteBody.Plaintext(it.filename, it.content) } + return autopilot.projectSecretsWrite(projectId, files, overwrite) + } + + // MARK: - Global search + + /** + * Run a combined threads + docs search for [query], debounced by 200 ms. + * Mirrors iOS `updateSearchQuery`: a blank query clears results; otherwise one + * `searchThreadsAndDocs` autopilot round trip populates both hit lists. Each + * keystroke cancels the previous in-flight search so only the latest applies. + */ + fun updateSearchQuery(query: String) { + _state.update { it.copy(searchQuery = query) } + searchJob?.cancel() + val trimmed = query.trim() + if (trimmed.isEmpty()) { + _state.update { + it.copy( + isSearching = false, + searchThreadHits = emptyList(), + searchDocHits = emptyList(), + searchProjectIDs = emptyList(), + ) + } + return + } + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + _state.update { it.copy(isSearching = true) } + try { + val result = autopilot.searchThreadsAndDocs(trimmed, limit = SEARCH_LIMIT) + // Ignore stale results whose query no longer matches the field. + if (_state.value.searchQuery.trim() != trimmed) return@launch + _state.update { + it.copy( + isSearching = false, + searchThreadHits = result.threadHits, + searchDocHits = result.docHits, + searchProjectIDs = result.projectIDs, + ) + } + } catch (t: Throwable) { + if (_state.value.searchQuery.trim() != trimmed) return@launch + Log.w(TAG, "search failed: ${t.message}") + _state.update { it.copy(isSearching = false) } + } + } + } + + /** Clear the search field and any results (e.g. when leaving the search UI). */ + fun clearSearch() { + searchJob?.cancel() + _state.update { + it.copy( + searchQuery = "", + isSearching = false, + searchThreadHits = emptyList(), + searchDocHits = emptyList(), + searchProjectIDs = emptyList(), + ) + } + } + fun respondToPermission(allow: Boolean, denyReason: String? = null) { val req = _state.value.pendingPermission ?: return _state.update { it.copy(pendingPermission = null) } @@ -856,6 +990,8 @@ class MobileAppState @Inject constructor( private const val TAG = "MobileAppState" private const val PAIRING_TIMEOUT_MS = 25_000L const val MESSAGE_PAGE_SIZE = 30 + private const val SEARCH_DEBOUNCE_MS = 200L + private const val SEARCH_LIMIT = 25 fun defaultDisplayName(): String = Build.MODEL.ifBlank { "Android" } 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 ecf283a4..d94baa0d 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 @@ -1,6 +1,7 @@ package app.rxlab.rxcode.state import app.rxlab.rxcode.proto.ChatMessage +import app.rxlab.rxcode.proto.DocsSearchHit import app.rxlab.rxcode.proto.MobileBranchBriefing import app.rxlab.rxcode.proto.MobileRunTaskSnapshot import app.rxlab.rxcode.proto.MobileThreadSummary @@ -10,6 +11,7 @@ import app.rxlab.rxcode.proto.PermissionRequestPayload import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ProjectBranchInfo import app.rxlab.rxcode.proto.RunProfile +import app.rxlab.rxcode.proto.SearchHit import app.rxlab.rxcode.proto.SessionSummary import app.rxlab.rxcode.proto.ThreadChangesResultPayload import app.rxlab.rxcode.relay.RelayClient @@ -80,6 +82,17 @@ data class MobileState( * then clears it via [MobileAppState.consumePendingNotificationDeepLink]. */ val pendingNotificationSessionID: String? = null, + + /** + * Global search state (threads + published docs), mirroring iOS + * `MobileAppState`. Populated by [MobileAppState.updateSearchQuery], which + * runs one combined `searchThreadsAndDocs` autopilot call per debounced query. + */ + val searchQuery: String = "", + val searchThreadHits: List<SearchHit> = emptyList(), + val searchDocHits: List<DocsSearchHit> = emptyList(), + val searchProjectIDs: List<UUID> = emptyList(), + val isSearching: Boolean = false, ) { val isPaired: Boolean get() = activeDesktopPubkey.isNotEmpty() 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 66638afe..149f60e7 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 @@ -3,6 +3,7 @@ package app.rxlab.rxcode.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -17,6 +18,7 @@ import app.rxlab.rxcode.state.MobileState 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 @@ -120,6 +122,14 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { viewModel = viewModel, onSettingsClick = { currentTab = RootTab.Settings.name }, ) + RootTab.Search -> SearchScreen( + state = state, + viewModel = viewModel, + onOpenSession = { sid -> + viewModel.selectSession(sid) + currentTab = RootTab.Projects.name + }, + ) RootTab.Settings -> SettingsScreen( state = state, viewModel = viewModel, @@ -136,10 +146,11 @@ enum class RootTab( ) { Briefing("Briefing", Icons.Outlined.Description), Projects("Projects", Icons.Outlined.Folder), + Search("Search", Icons.Outlined.Search), Settings("Settings", Icons.Outlined.Settings); companion object { - val allTabs: List<RootTab> = listOf(Briefing, Projects, Settings) + val allTabs: List<RootTab> = listOf(Briefing, Projects, Search, Settings) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt new file mode 100644 index 00000000..38bf45a2 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt @@ -0,0 +1,239 @@ +package app.rxlab.rxcode.ui.autopilot + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MergeType +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.HourglassEmpty +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalUriHandler +import app.rxlab.rxcode.proto.AutopilotProjectStatus +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.ui.sheets.ProjectReleaseCreateSheet +import app.rxlab.rxcode.ui.sheets.ProjectSecretsDownloadSheet +import kotlinx.coroutines.launch + +private const val SECRETS_SETUP_PROMPT = + "Set up encrypted secrets for this repository so I can sync environment files securely." +private const val DOCS_SETUP_PROMPT = + "Set up documentation publishing for this repository so its docs are indexed into RxCode docs search." +private const val RELEASE_SETUP_PROMPT = + "Set up a release workflow for this repository (semantic-release + a GitHub Actions release workflow)." + +/** + * One-to-one mobile mirror of the desktop project/briefing autopilot context + * menu (`ProjectAutopilotMenuItems` on iOS), plus the per-screen extras: a + * "Create Pull Request" / "Open on GitHub" pair (briefing detail) and an + * optional destructive "Delete Project" (session list). + * + * Drop into a `TopAppBar`'s `actions = { }`. Autopilot items only appear for + * repo-backed projects, gated on the per-project [AutopilotProjectStatus] this + * loads on first composition. Every item is desktop-mediated except "Download + * Secret", which decrypts on-device and relays plaintext for the Mac to write. + * + * @param branch current branch — enables "Create Pull Request" (hidden when null + * or `"unknown"`) and seeds the release form's branch picker. + * @param onOpenSession navigate to a chat after a setup action creates one. + * @param onProjectDeleted called after confirming "Delete Project" so the host + * can pop back (only reachable when [includeDeleteProject] is true). + */ +@Composable +fun ProjectActionsMenu( + project: Project, + branch: String?, + branchInfo: ProjectBranchInfo?, + viewModel: MobileAppState, + includeDeleteProject: Boolean = false, + onOpenSession: (String) -> Unit = {}, + onProjectDeleted: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val hasRepo = !project.gitHubRepo.isNullOrEmpty() + val canCreatePR = branch != null && !branch.equals("unknown", ignoreCase = true) + + var menuOpen by remember { mutableStateOf(false) } + var status by remember(project.id) { mutableStateOf<AutopilotProjectStatus?>(null) } + var showDownload by remember { mutableStateOf(false) } + var showReleaseCreate by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var isCreatingPR by remember { mutableStateOf(false) } + var info by remember { mutableStateOf<String?>(null) } + + // Only repo-backed projects have autopilot state to load. + androidx.compose.runtime.LaunchedEffect(project.id) { + if (hasRepo) status = runCatching { viewModel.projectAutopilotStatus(project.id) }.getOrNull() + } + + fun startSetupChat(prompt: String) { + menuOpen = false + val sessionId = viewModel.startNewSession(projectId = project.id, initialText = prompt) + onOpenSession(sessionId) + } + + fun createPullRequest() { + if (isCreatingPR || branch == null) return + isCreatingPR = true + menuOpen = false + scope.launch { + try { + val url = viewModel.requestProjectCreatePullRequest(project.id, branch) + viewModel.requestSnapshot("pr_created") + uriHandler.openUri(url) + } catch (t: Throwable) { + info = t.message ?: "Couldn't create the pull request." + } finally { + isCreatingPR = false + } + } + } + + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = "Project actions") + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (hasRepo) { + val loaded = status + if (loaded == null) { + DropdownMenuItem( + text = { Text("Loading Autopilot…") }, + leadingIcon = { Icon(Icons.Outlined.HourglassEmpty, contentDescription = null) }, + enabled = false, + onClick = {}, + ) + } else { + // Secrets: download when present, otherwise offer setup. + if (loaded.hasSecrets) { + DropdownMenuItem( + text = { Text("Download Secret") }, + leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, + onClick = { menuOpen = false; showDownload = true }, + ) + } else { + DropdownMenuItem( + text = { Text("Set Up Secrets") }, + leadingIcon = { Icon(Icons.Outlined.Key, contentDescription = null) }, + onClick = { startSetupChat(SECRETS_SETUP_PROMPT) }, + ) + } + // Docs: only offer setup when not yet indexed. + if (!loaded.hasDocs) { + DropdownMenuItem( + text = { Text("Set Up Docs") }, + leadingIcon = { Icon(Icons.Outlined.Book, contentDescription = null) }, + onClick = { startSetupChat(DOCS_SETUP_PROMPT) }, + ) + } + // Release: create when configured, otherwise offer setup. + if (loaded.hasRelease) { + DropdownMenuItem( + text = { Text("Create Release") }, + leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, + onClick = { menuOpen = false; showReleaseCreate = true }, + ) + } else { + DropdownMenuItem( + text = { Text("Set Up Release Workflow") }, + leadingIcon = { Icon(Icons.Outlined.Sell, contentDescription = null) }, + onClick = { startSetupChat(RELEASE_SETUP_PROMPT) }, + ) + } + if (canCreatePR) { + DropdownMenuItem( + text = { Text("Create Pull Request") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.MergeType, contentDescription = null) }, + enabled = !isCreatingPR, + onClick = { createPullRequest() }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Open on GitHub") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, + onClick = { + menuOpen = false + uriHandler.openUri("https://github.com/${project.gitHubRepo}") + }, + ) + } + } + + if (includeDeleteProject) { + if (hasRepo) HorizontalDivider() + DropdownMenuItem( + text = { Text("Delete Project", color = MaterialTheme.colorScheme.error) }, + leadingIcon = { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + }, + onClick = { menuOpen = false; showDeleteConfirm = true }, + ) + } + } + + if (showDownload) { + ProjectSecretsDownloadSheet(viewModel = viewModel, project = project, onDismiss = { showDownload = false }) + } + + if (showReleaseCreate) { + ProjectReleaseCreateSheet( + viewModel = viewModel, + project = project, + branchInfo = branchInfo, + onDismiss = { showReleaseCreate = false }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + icon = { Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Delete project?") }, + text = { + Text( + "This permanently removes \"${project.name}\" and all its threads, search index, and memory " + + "from both your Mac and this device.", + ) + }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.deleteProject(project.id) + onProjectDeleted() + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } + + info?.let { message -> + AlertDialog( + onDismissRequest = { info = null }, + title = { Text("Couldn't Complete") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { info = null }) { Text("OK") } }, + ) + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index 020489cc..03205c0b 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.MobileThreadSummary import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent import app.rxlab.rxcode.ui.util.RxMarkdownText @@ -111,6 +112,19 @@ fun BriefingDetailScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + // Autopilot / GitHub context menu, 1:1 with iOS + // MobileBriefingDetailView. Repo-gated like the desktop menu. + project?.takeIf { !it.gitHubRepo.isNullOrEmpty() }?.let { repoProject -> + ProjectActionsMenu( + project = repoProject, + branch = groupKey.branch, + branchInfo = branchInfo, + viewModel = viewModel, + onOpenSession = onOpenSession, + ) + } + }, ) }, floatingActionButton = { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt new file mode 100644 index 00000000..9ef0eb2e --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt @@ -0,0 +1,321 @@ +package app.rxlab.rxcode.ui.search + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Article +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.DocsSearchHit +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.SearchHit +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.util.RxMarkdownText + +private enum class SearchScope(val label: String) { ALL("All"), THREADS("Threads"), DOCS("Docs") } + +/** + * Global search across threads + published docs. Mirrors iOS `MobileSearchView`: + * a search field, an All / Threads / Docs scope selector with live match counts, + * and result cards. Tapping a thread opens its chat; tapping a doc opens an + * on-demand markdown viewer. One debounced `searchThreadsAndDocs` autopilot call + * (see [MobileAppState.updateSearchQuery]) feeds both result lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + state: MobileState, + viewModel: MobileAppState, + onOpenSession: (String) -> Unit, +) { + var scope by remember { mutableStateOf(SearchScope.ALL) } + var openDoc by remember { mutableStateOf<DocsSearchHit?>(null) } + val projectsById = remember(state.projects) { state.projects.associateBy { it.id } } + val hasQuery = state.searchQuery.trim().isNotEmpty() + val settled = hasQuery && !state.isSearching + + fun count(scopeFor: SearchScope): Int = when (scopeFor) { + SearchScope.ALL -> state.searchThreadHits.size + state.searchDocHits.size + SearchScope.THREADS -> state.searchThreadHits.size + SearchScope.DOCS -> state.searchDocHits.size + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column(Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp)) { + TextField( + value = state.searchQuery, + onValueChange = viewModel::updateSearchQuery, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search threads and docs") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (hasQuery) { + IconButton(onClick = { viewModel.clearSearch() }) { + Icon(Icons.Outlined.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + ), + shape = MaterialTheme.shapes.large, + ) + Row(Modifier.fillMaxWidth().padding(top = 10.dp)) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SearchScope.entries.forEachIndexed { index, item -> + SegmentedButton( + selected = scope == item, + onClick = { scope = item }, + shape = SegmentedButtonDefaults.itemShape(index, SearchScope.entries.size), + ) { + Text(if (settled) "${item.label} (${count(item)})" else item.label) + } + } + } + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + !hasQuery -> CenteredHint("Search your threads and published docs.") + state.isSearching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Text("Searching…", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else -> { + val showThreads = scope != SearchScope.DOCS + val showDocs = scope != SearchScope.THREADS + val threadHits = if (showThreads) state.searchThreadHits else emptyList() + val docHits = if (showDocs) state.searchDocHits else emptyList() + if (threadHits.isEmpty() && docHits.isEmpty()) { + CenteredHint("No results for \"${state.searchQuery.trim()}\".") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (threadHits.isNotEmpty()) { + item("threads-header") { SectionLabel("Threads") } + items(threadHits, key = { "t:${it.sessionID}" }) { hit -> + ThreadHitCard(hit = hit, project = projectsById[hit.projectID], onClick = { onOpenSession(hit.sessionID) }) + } + } + if (docHits.isNotEmpty()) { + item("docs-header") { SectionLabel("Docs") } + items(docHits, key = { "d:${it.stableId}" }) { hit -> + DocHitCard(hit = hit, onClick = { openDoc = hit }) + } + } + } + } + } + } + } + } + + openDoc?.let { hit -> + DocViewerSheet(viewModel = viewModel, hit = hit, onDismiss = { openDoc = null }) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) +} + +@Composable +private fun CenteredHint(text: String) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ThreadHitCard(hit: SearchHit, project: Project?, onClick: () -> Unit) { + ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + hit.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + if (hit.snippet.isNotBlank()) { + Text( + hit.snippet, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + project?.name?.let { Badge(it) } + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun DocHitCard(hit: DocsSearchHit, onClick: () -> Unit) { + val title = hit.docId.ifBlank { hit.repositoryFullName ?: "Document" } + ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.AutoMirrored.Outlined.Article, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1) + hit.snippet?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + hit.repositoryFullName?.let { Badge(it) } + } + Icon( + Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun Badge(text: String) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.small, + ) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +/** + * On-demand doc viewer. Fetches the rendered markdown for the tapped hit via the + * docs `getDocument` autopilot call and renders it; mirrors iOS `MobileDocumentView`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DocViewerSheet(viewModel: MobileAppState, hit: DocsSearchHit, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var content by remember { mutableStateOf<String?>(null) } + var error by remember { mutableStateOf<String?>(null) } + var isLoading by remember { mutableStateOf(true) } + val repoId = hit.docsRepositoryId ?: hit.repositoryFullName + + LaunchedEffect(hit.stableId) { + if (repoId == null) { + error = "This document has no repository reference." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val detail = viewModel.autopilot.getDocument(repoId, hit.docId) + content = detail.currentVersion?.content ?: "" + } catch (t: Throwable) { + error = t.message ?: "Couldn't load the document." + } + isLoading = false + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + hit.docId.ifBlank { hit.repositoryFullName ?: "Document" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + when { + isLoading -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + error != null -> Text(error!!, color = MaterialTheme.colorScheme.error) + content.isNullOrBlank() -> Text("This document is empty.", color = MaterialTheme.colorScheme.onSurfaceVariant) + else -> RxMarkdownText(markdown = content!!) + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index 3f2ab8c4..df943af0 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -60,6 +60,7 @@ import app.rxlab.rxcode.proto.SessionSummary import app.rxlab.rxcode.proto.TodoItem import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.sheets.RenameThreadSheet import app.rxlab.rxcode.ui.util.HapticEvent @@ -109,6 +110,22 @@ fun SessionsScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + // Project-level context menu (autopilot actions + Delete + // Project), 1:1 with iOS SessionsList. The per-thread row + // menu (rename/archive/delete) stays on each card below. + project?.let { proj -> + ProjectActionsMenu( + project = proj, + branch = branchInfo?.currentBranch, + branchInfo = branchInfo, + viewModel = viewModel, + includeDeleteProject = true, + onOpenSession = onNewThread, + onProjectDeleted = onBack, + ) + } + }, ) }, floatingActionButton = { 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 8945bdac..d1516d93 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 @@ -326,9 +326,9 @@ private fun PairedDesktopCard( Surface( shape = CircleShape, color = if (isActive) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.secondaryContainer, + else MaterialTheme.colorScheme.secondary, contentColor = if (isActive) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSecondaryContainer, + else MaterialTheme.colorScheme.onSecondary, modifier = Modifier.size(44.dp), ) { Box(contentAlignment = Alignment.Center) { @@ -345,7 +345,7 @@ private fun PairedDesktopCard( Text( it, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt new file mode 100644 index 00000000..96df4686 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ProjectAutopilotSheets.kt @@ -0,0 +1,547 @@ +package app.rxlab.rxcode.ui.sheets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.MobileReleaseWorkflow +import app.rxlab.rxcode.proto.MobileReleaseWorkflowInput +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.proto.ReleaseDispatchRequest +import app.rxlab.rxcode.proto.SecretsEnvironment +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.ui.autopilot.AutopilotErrorRow +import app.rxlab.rxcode.ui.autopilot.AutopilotSuccessRow +import kotlinx.coroutines.launch + +/** + * On-device "Download Secret" form. Mirrors iOS `ProjectSecretsDownloadSheet`: + * pick an environment, decrypt it on-device with the phone's passkey-derived + * KEK, then relay the plaintext for the Mac to write into the project folder. + * Files skipped because they already exist surface a prompt to enable overwrite. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectSecretsDownloadSheet( + viewModel: MobileAppState, + project: Project, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + val repo = project.gitHubRepo + + var environments by remember { mutableStateOf<List<SecretsEnvironment>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf<String?>(null) } + var selectedEnvId by remember { mutableStateOf<String?>(null) } + var overwrite by remember { mutableStateOf(false) } + var isDownloading by remember { mutableStateOf(false) } + var actionError by remember { mutableStateOf<String?>(null) } + var conflicts by remember { mutableStateOf<List<String>>(emptyList()) } + + androidx.compose.runtime.LaunchedEffect(project.id) { + if (repo == null) { + loadError = "This project is not linked to a GitHub repo." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val envs = viewModel.autopilot.listSecretEnvironments(repo) + environments = envs + if (envs.size == 1) selectedEnvId = envs.first().id + } catch (t: Throwable) { + loadError = t.message ?: "Couldn't load environments." + } + isLoading = false + } + + fun download() { + val envId = selectedEnvId ?: return + if (repo == null) return + isDownloading = true + actionError = null + conflicts = emptyList() + scope.launch { + try { + val result = viewModel.downloadProjectSecrets(context, project.id, repo, envId, overwrite) + if (result.conflicts.isNotEmpty()) conflicts = result.conflicts else onDismiss() + } catch (t: Throwable) { + actionError = t.message ?: "Couldn't download the secrets." + } finally { + isDownloading = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SheetTopBar( + title = "Download Secret", + actionLabel = "Download", + actionEnabled = selectedEnvId != null && !isDownloading, + actionBusy = isDownloading, + onCancel = onDismiss, + onAction = ::download, + ) + + when { + loadError != null -> Text(loadError!!, color = MaterialTheme.colorScheme.error) + isLoading && environments.isEmpty() -> CenteredSpinner() + environments.isEmpty() -> Text( + "This repository has no secret environments to download.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + Text( + "Environment", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + environments.forEach { env -> + SelectableRow( + label = env.name, + selected = selectedEnvId == env.id, + onClick = { selectedEnvId = env.id }, + ) + } + Text( + "Files are decrypted on this device with your passkey, then written into the project folder on your Mac.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Overwrite existing files", style = MaterialTheme.typography.bodyMedium) + Switch(checked = overwrite, onCheckedChange = { overwrite = it }) + } + if (conflicts.isNotEmpty()) { + AutopilotErrorRow( + "These files already exist and were skipped: ${conflicts.joinToString(", ")}. " + + "Turn on overwrite to replace them.", + ) + } + if (actionError != null) { + Text(actionError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } + Spacer(Modifier.height(4.dp)) + } + } +} + +/** + * On-device "Create Release" form. Mirrors iOS `ProjectReleaseCreateSheet`: + * resolve the project's release workflows, pick a dispatchable one, fill its + * `workflow_dispatch` inputs, and trigger it (the desktop only relays). The next + * version is computed by semantic-release from the commit history. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectReleaseCreateSheet( + viewModel: MobileAppState, + project: Project, + branchInfo: ProjectBranchInfo?, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden }, + ) + val repo = project.gitHubRepo.orEmpty() + val branchOptions = remember(branchInfo) { + val current = branchInfo?.currentBranch + val all = branchInfo?.availableBranches.orEmpty() + when { + all.isNotEmpty() && current != null -> listOf(current) + all.filter { it != current } + all.isNotEmpty() -> all + current != null -> listOf(current) + else -> emptyList() + } + } + + var workflows by remember { mutableStateOf<List<MobileReleaseWorkflow>>(emptyList()) } + var selectedWorkflowId by remember { mutableStateOf<String?>(null) } + var branch by remember { mutableStateOf(branchInfo?.currentBranch ?: branchOptions.firstOrNull() ?: "main") } + var inputValues by remember { mutableStateOf<Map<String, String>>(emptyMap()) } + var isLoading by remember { mutableStateOf(true) } + var isDispatching by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf<String?>(null) } + var dispatchError by remember { mutableStateOf<String?>(null) } + var dispatchedUrl by remember { mutableStateOf<String?>(null) } + + val dispatchable = remember(workflows) { workflows.filter { it.hasWorkflowDispatch } } + val selectedWorkflow = remember(workflows, selectedWorkflowId) { workflows.firstOrNull { it.id == selectedWorkflowId } } + + fun seedDefaults(workflow: MobileReleaseWorkflow?) { + val inputs = workflow?.inputs ?: return + val next = inputValues.toMutableMap() + inputs.forEach { input -> + if (next[input.name] == null) { + next[input.name] = if (input.type == "boolean") { + if (input.defaultBool == true) "true" else "false" + } else { + input.defaultString ?: input.options?.firstOrNull() ?: "" + } + } + } + inputValues = next + } + + androidx.compose.runtime.LaunchedEffect(project.id) { + if (repo.isEmpty()) { + loadError = "This project is not linked to a GitHub repo." + isLoading = false + return@LaunchedEffect + } + isLoading = true + try { + val list = viewModel.autopilot.listReleaseWorkflows(repo) + workflows = list + val preferred = list.firstOrNull { it.isSelected && it.hasWorkflowDispatch } + ?: list.firstOrNull { it.hasWorkflowDispatch } + selectedWorkflowId = preferred?.id + seedDefaults(preferred) + } catch (t: Throwable) { + loadError = t.message ?: "Couldn't load workflows." + } + isLoading = false + } + + fun dispatch() { + val workflow = selectedWorkflow ?: return + val resolvedBranch = branch.trim() + if (resolvedBranch.isEmpty()) return + isDispatching = true + dispatchError = null + scope.launch { + try { + val result = viewModel.autopilot.dispatchRelease( + repo, + ReleaseDispatchRequest(workflowId = workflow.id, branch = resolvedBranch, inputs = inputValues), + ) + when { + result.workflowRunUrl != null -> dispatchedUrl = result.workflowRunUrl + result.success == false -> dispatchError = result.error ?: "Failed to trigger the release workflow." + else -> dispatchedUrl = "https://github.com/$repo/actions" + } + } catch (t: Throwable) { + dispatchError = t.message ?: "Failed to trigger the release workflow." + } finally { + isDispatching = false + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 16.dp) + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SheetTopBar( + title = "Create Release", + actionLabel = if (dispatchedUrl == null) "Create" else "Done", + actionEnabled = dispatchedUrl != null || + (!isDispatching && selectedWorkflow != null && branch.trim().isNotEmpty()), + actionBusy = isDispatching, + onCancel = onDismiss, + onAction = { if (dispatchedUrl != null) onDismiss() else dispatch() }, + ) + + when { + isLoading -> CenteredSpinner() + loadError != null -> Text(loadError!!, color = MaterialTheme.colorScheme.error) + dispatchable.isEmpty() -> Text( + "No dispatchable release workflow found for $repo. Add a workflow_dispatch release " + + "workflow and rescan, then try again.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> { + LabeledValue("Repository", repo) + if (dispatchable.size > 1) { + PickerRow( + label = "Workflow", + value = selectedWorkflow?.displayName ?: "Select", + options = dispatchable.map { it.displayName }, + onSelectIndex = { idx -> + val wf = dispatchable[idx] + selectedWorkflowId = wf.id + seedDefaults(wf) + }, + ) + } else { + LabeledValue("Workflow", dispatchable.first().displayName) + } + if (branchOptions.isEmpty()) { + OutlinedTextField( + value = branch, + onValueChange = { branch = it }, + label = { Text("Branch") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } else { + PickerRow( + label = "Branch", + value = branch, + options = branchOptions, + onSelectIndex = { branch = branchOptions[it] }, + ) + } + Text( + "The next version is computed by semantic-release from the commit history.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + selectedWorkflow?.inputs?.takeIf { it.isNotEmpty() }?.let { inputs -> + Text( + "Workflow inputs", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + inputs.forEach { input -> + ReleaseInputField( + input = input, + value = inputValues[input.name], + onValueChange = { inputValues = inputValues + (input.name to it) }, + ) + } + } + + if (dispatchError != null) { + AutopilotErrorRow(dispatchError!!) + } + if (dispatchedUrl != null) { + AutopilotSuccessRow("Release workflow triggered") + TextButton(onClick = { uriHandler.openUri(dispatchedUrl!!) }) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(6.dp)) + Text("View workflow run on GitHub") + } + } + } + } + Spacer(Modifier.height(4.dp)) + } + } +} + +// MARK: - Shared sheet pieces + +@Composable +private fun SheetTopBar( + title: String, + actionLabel: String, + actionEnabled: Boolean, + actionBusy: Boolean, + onCancel: () -> Unit, + onAction: () -> Unit, +) { + Box(Modifier.fillMaxWidth().padding(top = 8.dp)) { + TextButton(onClick = onCancel, modifier = Modifier.align(Alignment.CenterStart)) { Text("Cancel") } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.align(Alignment.Center), + ) + Box(Modifier.align(Alignment.CenterEnd)) { + if (actionBusy) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + } else { + TextButton(onClick = onAction, enabled = actionEnabled) { + Text(actionLabel, fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun CenteredSpinner() { + Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun SelectableRow(label: String, selected: Boolean, onClick: () -> Unit) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + if (selected) Icon(Icons.Outlined.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun LabeledValue(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun PickerRow( + label: String, + value: String, + options: List<String>, + onSelectIndex: (Int) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box(Modifier.fillMaxWidth()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable { open = true }, + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Icon(Icons.Outlined.ExpandMore, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEachIndexed { idx, option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { open = false; onSelectIndex(idx) }, + ) + } + } + } +} + +@Composable +private fun ReleaseInputField( + input: MobileReleaseWorkflowInput, + value: String?, + onValueChange: (String) -> Unit, +) { + val label = input.name + (if (input.required) " *" else "") + when { + input.type == "boolean" -> { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Switch( + checked = (value ?: if (input.defaultBool == true) "true" else "false") == "true", + onCheckedChange = { onValueChange(if (it) "true" else "false") }, + ) + } + } + input.type == "choice" && input.options != null -> { + val options = input.options!! + PickerRow( + label = label, + value = value ?: input.defaultString ?: options.firstOrNull() ?: "", + options = options, + onSelectIndex = { onValueChange(options[it]) }, + ) + } + else -> { + OutlinedTextField( + value = value ?: input.defaultString ?: "", + onValueChange = onValueChange, + label = { Text(label) }, + supportingText = input.description?.takeIf { it.isNotEmpty() }?.let { { Text(it) } }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} From 4c03b8e0e765906e34f44f77890ff82f032a4fe1 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:55:57 +0800 Subject: [PATCH 2/4] feat(android): show loading dialog while creating a pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a non-dismissible progress dialog ("Creating Pull Request…") while the desktop pushes the branch and opens the PR, mirroring the iOS mobileAutopilotLoadingOverlay. The menu closes on tap, so without this there was no feedback until the browser opened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../rxcode/ui/autopilot/ProjectActionsMenu.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt index 38bf45a2..43acd0fb 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/autopilot/ProjectActionsMenu.kt @@ -1,5 +1,8 @@ package app.rxlab.rxcode.ui.autopilot +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MergeType import androidx.compose.material.icons.automirrored.outlined.OpenInNew @@ -10,6 +13,7 @@ import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Sell import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider @@ -24,7 +28,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.AutopilotProjectStatus import app.rxlab.rxcode.proto.Project import app.rxlab.rxcode.proto.ProjectBranchInfo @@ -228,6 +235,20 @@ fun ProjectActionsMenu( ) } + if (isCreatingPR) { + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text("Creating Pull Request…") }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Text("The Mac is pushing the branch and opening the PR.") + } + }, + ) + } + info?.let { message -> AlertDialog( onDismissRequest = { info = null }, From 375b84cae39b8536b5b4f14003392c3f0f08f9aa Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:29:30 +0800 Subject: [PATCH 3/4] feat: improve android search position --- .../Hooks/AppStateHookController.swift | 6 + RxCode/Views/Toolbar/ExternalEditorMenu.swift | 35 +++++- .../java/app/rxlab/rxcode/ui/RxCodeApp.kt | 117 +++++++++++------- .../ui/briefing/BriefingDetailScreen.kt | 5 + .../rxlab/rxcode/ui/briefing/BriefingPane.kt | 11 ++ .../rxcode/ui/briefing/BriefingScreen.kt | 5 + .../rxlab/rxcode/ui/projects/ProjectsPane.kt | 9 ++ .../rxcode/ui/projects/ProjectsScreen.kt | 5 + .../rxlab/rxcode/ui/search/SearchScreen.kt | 56 +++++---- .../rxcode/ui/sessions/SessionsScreen.kt | 5 + .../rxcode/ui/settings/SettingsScreen.kt | 37 ++++-- RxCodeMobile/Resources/Localizable.xcstrings | 6 + .../MobileAutopilotLoadingOverlay.swift | 54 ++++++++ .../Views/Autopilot/MobileSecretsView.swift | 49 +++++++- .../Views/MobileBriefingDetailView.swift | 6 +- 15 files changed, 319 insertions(+), 87 deletions(-) diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index e4941b9a..a9e3db9d 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -220,6 +220,12 @@ final class AppStateHookController: HookController { do { let envs = try await app.secrets.listEnvironments(repo: repoFullName).items return envs.map { HookChoice(id: $0.id, label: $0.name) } + } catch let SecretsService.ServiceError.apiError(status, _) where status == 404 { + // A 404 means the repo simply isn't registered for secrets yet — a + // definitive "no environments" answer, not a failed check. Return [] + // (not nil) so callers surface the "set up secrets" banner. + logger.debug("secretEnvironments: repo \(repoFullName, privacy: .public) not registered (404) — treating as no environments") + return [] } catch { // nil (not []) so callers don't mistake a failed/cancelled check for // a repo that genuinely has no environments and show a stale banner. diff --git a/RxCode/Views/Toolbar/ExternalEditorMenu.swift b/RxCode/Views/Toolbar/ExternalEditorMenu.swift index f4764432..02a5dd63 100644 --- a/RxCode/Views/Toolbar/ExternalEditorMenu.swift +++ b/RxCode/Views/Toolbar/ExternalEditorMenu.swift @@ -122,8 +122,23 @@ final class ExternalEditorService { // MARK: - ExternalEditorMenu struct ExternalEditorMenu: View { + @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + /// Pull request for the selected project's current branch, when one is known + /// (mirrors the briefing card). Drives the dedicated "Open Pull Request" item. + private func pullRequestURL(for project: Project) -> URL? { + _ = appState.ciStatusRevision + guard let status = appState.ciStatusByProject[project.id] else { return nil } + if let prUrl = status.prUrl, let url = URL(string: prUrl) { + return url + } + if let prNumber = status.prNumber { + return URL(string: "https://github.com/\(status.owner)/\(status.repo)/pull/\(prNumber)") + } + return nil + } + var body: some View { Menu { let editors = ExternalEditorService.shared.detectedEditors() @@ -141,12 +156,20 @@ struct ExternalEditorMenu: View { } } Divider() - if let project = windowState.selectedProject, - let gitHubURL = ExternalEditorService.shared.gitHubURL(for: project) { - Button { - NSWorkspace.shared.open(gitHubURL) - } label: { - Label("Open in GitHub", systemImage: "globe") + if let project = windowState.selectedProject { + if let prURL = pullRequestURL(for: project) { + Button { + NSWorkspace.shared.open(prURL) + } label: { + Label("Open Pull Request", systemImage: "arrow.triangle.pull") + } + } + if let repoURL = ExternalEditorService.shared.gitHubURL(for: project) { + Button { + NSWorkspace.shared.open(repoURL) + } label: { + Label("Open Repository", systemImage: "globe") + } } } Button { 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 149f60e7..ba7d770b 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,12 +1,15 @@ package app.rxlab.rxcode.ui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon +import androidx.compose.material3.Surface 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.getValue @@ -78,6 +81,12 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { var currentTab by rememberSaveable { mutableStateOf(RootTab.Briefing.name) } val tab = RootTab.valueOf(currentTab) + // 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 + // (mirrors iOS, where search lives in the toolbar, not the tab bar). + var showSearch by rememberSaveable { mutableStateOf(false) } + val openSearch = { showSearch = true } + // FCM notification tap: select the target session and switch to Projects // (where ProjectsPaneScreen reacts to `activeSessionID` and pushes the // chat detail). Buffered in state by MobileAppState so we route the UI @@ -89,53 +98,68 @@ fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { viewModel.consumePendingNotificationDeepLink() } - NavigationSuiteScaffold( - navigationSuiteItems = { - RootTab.allTabs.forEach { t -> - item( - selected = t == tab, - onClick = { currentTab = t.name }, - icon = { Icon(t.icon, contentDescription = null) }, - label = { Text(t.label) }, + Box(Modifier.fillMaxSize()) { + NavigationSuiteScaffold( + navigationSuiteItems = { + RootTab.allTabs.forEach { t -> + item( + selected = t == tab, + onClick = { currentTab = t.name }, + 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, ) } } - ) { - 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 - }, - ) - RootTab.Projects -> ProjectsPaneScreen( - state = state, - viewModel = viewModel, - onSettingsClick = { currentTab = RootTab.Settings.name }, - ) - RootTab.Search -> SearchScreen( - state = state, - viewModel = viewModel, - onOpenSession = { sid -> - viewModel.selectSession(sid) - currentTab = RootTab.Projects.name - }, - ) - RootTab.Settings -> SettingsScreen( - state = state, - viewModel = viewModel, - onBack = { currentTab = RootTab.Briefing.name }, - onPairNewMac = { showPairingFromSplash = true }, - ) + + if (showSearch) { + Surface(Modifier.fillMaxSize()) { + SearchScreen( + state = state, + viewModel = viewModel, + onOpenSession = { sid -> + viewModel.selectSession(sid) + currentTab = RootTab.Projects.name + showSearch = false + }, + onClose = { + showSearch = false + viewModel.clearSearch() + }, + ) + } } } } @@ -146,11 +170,10 @@ enum class RootTab( ) { Briefing("Briefing", Icons.Outlined.Description), Projects("Projects", Icons.Outlined.Folder), - Search("Search", Icons.Outlined.Search), Settings("Settings", Icons.Outlined.Settings); companion object { - val allTabs: List<RootTab> = listOf(Briefing, Projects, Search, Settings) + val allTabs: List<RootTab> = listOf(Briefing, Projects, Settings) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt index 03205c0b..32e4a1e7 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.automirrored.outlined.TextSnippet import androidx.compose.material3.AssistChip @@ -77,6 +78,7 @@ fun BriefingDetailScreen( onOpenSession: (String) -> Unit, onNewThread: (sessionId: String) -> Unit, selectedSessionId: String? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val group by remember(state.branchBriefings, state.threadSummaries, groupKey) { @@ -113,6 +115,9 @@ fun BriefingDetailScreen( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } // Autopilot / GitHub context menu, 1:1 with iOS // MobileBriefingDetailView. Repo-gated like the desktop menu. project?.takeIf { !it.gitHubRepo.isNullOrEmpty() }?.let { repoProject -> diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt index 5fcb34f8..a3e0665d 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt @@ -53,10 +53,12 @@ fun BriefingPaneScreen( viewModel: MobileAppState, onOpenSession: (String) -> Unit, onNewThread: (projectId: java.util.UUID) -> Unit, + onOpenSearch: () -> Unit, ) { BriefingContentSplitPane( state = state, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } @@ -65,6 +67,7 @@ fun BriefingPaneScreen( private fun BriefingContentSplitPane( state: MobileState, viewModel: MobileAppState, + onOpenSearch: () -> Unit, ) { var selectedGroupKey by rememberSaveable(stateSaver = briefingGroupKeySaver) { mutableStateOf<BriefingGroupKey?>(null) @@ -126,6 +129,7 @@ private fun BriefingContentSplitPane( selectedSessionId = null viewModel.selectSession(null) }, + onOpenSearch = onOpenSearch, availableWidth = maxWidth, ) return@BoxWithConstraints @@ -154,6 +158,7 @@ private fun BriefingContentSplitPane( selectedSessionId = null viewModel.selectSession(null) }, + onOpenSearch = onOpenSearch, ) } } @@ -168,6 +173,7 @@ private fun CompactBriefingPane( onSelectSession: (String) -> Unit, onClearGroup: () -> Unit, onClearSession: () -> Unit, + onOpenSearch: () -> Unit, ) { val sid = selectedSessionId val key = selectedGroupKey @@ -191,6 +197,7 @@ private fun CompactBriefingPane( onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } else -> { @@ -199,6 +206,7 @@ private fun CompactBriefingPane( viewModel = viewModel, onOpenGroup = onSelectGroup, selectedGroupKey = selectedGroupKey, + onOpenSearch = onOpenSearch, ) } } @@ -214,6 +222,7 @@ private fun WideBriefingPane( onSelectSession: (String) -> Unit, onClearGroup: () -> Unit, onClearSession: () -> Unit, + onOpenSearch: () -> Unit, availableWidth: Dp, ) { var isDetailExpanded by rememberSaveable { mutableStateOf(false) } @@ -243,6 +252,7 @@ private fun WideBriefingPane( onSelectSession(newSessionId) }, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } else { BriefingScreen( @@ -250,6 +260,7 @@ private fun WideBriefingPane( viewModel = viewModel, onOpenGroup = onSelectGroup, selectedGroupKey = selectedGroupKey, + onOpenSearch = onOpenSearch, ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt index 6fd0907a..5246be93 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.FilterAltOff import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Schedule @@ -79,6 +80,7 @@ fun BriefingScreen( viewModel: MobileAppState, onOpenGroup: (BriefingGroupKey) -> Unit, selectedGroupKey: BriefingGroupKey? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val scope = rememberCoroutineScope() @@ -134,6 +136,9 @@ fun BriefingScreen( CenterAlignedTopAppBar( title = { Text("Briefing") }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } if (hasAnyData) { Box { IconButton(onClick = { menuOpen = true }) { diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt index 6fdfebab..8652bdd4 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt @@ -66,6 +66,7 @@ fun ProjectsPaneScreen( state: MobileState, viewModel: MobileAppState, onSettingsClick: () -> Unit, + onOpenSearch: () -> Unit, ) { var selectedProjectId by rememberSaveable(stateSaver = uuidSaver) { mutableStateOf<UUID?>(null) @@ -85,6 +86,7 @@ fun ProjectsPaneScreen( onSelectProject = { selectedProjectId = it }, onSettingsClick = onSettingsClick, onBackToProjects = { selectedProjectId = null }, + onOpenSearch = onOpenSearch, availableWidth = maxWidth, ) } else { @@ -95,6 +97,7 @@ fun ProjectsPaneScreen( onSelectProject = { selectedProjectId = it }, onSettingsClick = onSettingsClick, onBackToProjects = { selectedProjectId = null }, + onOpenSearch = onOpenSearch, ) } } @@ -109,6 +112,7 @@ private fun CollapsingProjectsPane( onSelectProject: (UUID) -> Unit, onSettingsClick: () -> Unit, onBackToProjects: () -> Unit, + onOpenSearch: () -> Unit, ) { val navigator = rememberListDetailPaneScaffoldNavigator<String>() val scope = rememberCoroutineScope() @@ -152,6 +156,7 @@ private fun CollapsingProjectsPane( onProjectClick = { project -> onSelectProject(project.id) }, onSettingsClick = onSettingsClick, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } else { SessionsScreen( @@ -171,6 +176,7 @@ private fun CollapsingProjectsPane( }, onBack = onBackToProjects, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } } @@ -204,6 +210,7 @@ private fun WideProjectsPane( onSelectProject: (UUID) -> Unit, onSettingsClick: () -> Unit, onBackToProjects: () -> Unit, + onOpenSearch: () -> Unit, availableWidth: Dp, ) { var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) } @@ -251,6 +258,7 @@ private fun WideProjectsPane( }, onSettingsClick = onSettingsClick, viewModel = viewModel, + onOpenSearch = onOpenSearch, ) } else { SessionsScreen( @@ -267,6 +275,7 @@ private fun WideProjectsPane( onBack = onBackToProjects, viewModel = viewModel, selectedSessionId = selectedSessionId, + onOpenSearch = onOpenSearch, ) } } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt index 4e8bcbab..ce8ebe89 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -59,6 +60,7 @@ fun ProjectsScreen( onSettingsClick: () -> Unit, selectedProjectId: java.util.UUID? = null, viewModel: MobileAppState? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val scope = rememberCoroutineScope() @@ -69,6 +71,9 @@ fun ProjectsScreen( CenterAlignedTopAppBar( title = { Text("Projects") }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } IconButton(onClick = onSettingsClick) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt index 9ef0eb2e..7635eede 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/search/SearchScreen.kt @@ -1,5 +1,6 @@ package app.rxlab.rxcode.ui.search +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,6 +15,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Clear @@ -67,7 +69,10 @@ fun SearchScreen( state: MobileState, viewModel: MobileAppState, onOpenSession: (String) -> Unit, + onClose: () -> Unit, ) { + BackHandler(onBack = onClose) + var scope by remember { mutableStateOf(SearchScope.ALL) } var openDoc by remember { mutableStateOf<DocsSearchHit?>(null) } val projectsById = remember(state.projects) { state.projects.associateBy { it.id } } @@ -83,30 +88,35 @@ fun SearchScreen( Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { - Column(Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp)) { - TextField( - value = state.searchQuery, - onValueChange = viewModel::updateSearchQuery, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search threads and docs") }, - leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, - trailingIcon = { - if (hasQuery) { - IconButton(onClick = { viewModel.clearSearch() }) { - Icon(Icons.Outlined.Clear, contentDescription = "Clear") + Column(Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Close search") + } + TextField( + value = state.searchQuery, + onValueChange = viewModel::updateSearchQuery, + modifier = Modifier.weight(1f), + placeholder = { Text("Search threads and docs") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (hasQuery) { + IconButton(onClick = { viewModel.clearSearch() }) { + Icon(Icons.Outlined.Clear, contentDescription = "Clear") + } } - } - }, - singleLine = true, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, - ), - shape = MaterialTheme.shapes.large, - ) + }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + ), + shape = MaterialTheme.shapes.large, + ) + } Row(Modifier.fillMaxWidth().padding(top = 10.dp)) { SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { SearchScope.entries.forEachIndexed { index, item -> diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index df943af0..79066402 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.outlined.DriveFileRenameOutline import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -87,6 +88,7 @@ fun SessionsScreen( onBack: () -> Unit, viewModel: MobileAppState, selectedSessionId: String? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() val project = state.projects.firstOrNull { it.id == projectId } @@ -111,6 +113,9 @@ fun SessionsScreen( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } // Project-level context menu (autopilot actions + Delete // Project), 1:1 with iOS SessionsList. The per-thread row // menu (rename/archive/delete) stays on each card below. 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 d1516d93..29ddda59 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 @@ -25,6 +25,7 @@ import androidx.compose.material.icons.outlined.DesktopMac import androidx.compose.material.icons.outlined.Flight import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Router +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults @@ -32,8 +33,10 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -79,6 +82,7 @@ fun SettingsScreen( viewModel: MobileAppState, onBack: () -> Unit, onPairNewMac: (() -> Unit)? = null, + onOpenSearch: () -> Unit = {}, ) { val haptics = rememberHaptics() var unpairTarget by remember { mutableStateOf<PairedDesktop?>(null) } @@ -104,6 +108,11 @@ fun SettingsScreen( Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = onOpenSearch) { + Icon(Icons.Outlined.Search, contentDescription = "Search") + } + }, ) }, ) { padding -> @@ -351,17 +360,31 @@ private fun PairedDesktopCard( } } if (isActive) { - Icon( - Icons.Outlined.CheckCircle, - contentDescription = "Active", - tint = MaterialTheme.colorScheme.primary, - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = "Active", + modifier = Modifier.size(20.dp), + ) + } + } } - IconButton(onClick = onUnpair) { + FilledTonalIconButton( + onClick = onUnpair, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { Icon( Icons.Outlined.Delete, contentDescription = "Unpair", - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 07f42087..0730b4c5 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -5911,6 +5911,9 @@ } } } + }, + "The Mac is pushing the branch and opening the PR." : { + }, "The Mac returned an empty response." : { "localizations" : { @@ -6655,6 +6658,9 @@ } } } + }, + "Working…" : { + }, "Xcode" : { "localizations" : { diff --git a/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift index d6c37981..ab33acdc 100644 --- a/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift +++ b/RxCodeMobile/Views/Autopilot/MobileAutopilotLoadingOverlay.swift @@ -40,3 +40,57 @@ extension View { .animation(.easeInOut(duration: 0.2), value: isLoading) } } + +/// A centered, alert-style dialog (dimmed scrim + material card) shown while an +/// action runs. Unlike `MobileAutopilotLoadingOverlay`, this keeps the screen +/// visible behind a dimming scrim — use it for *actions* (e.g. creating a PR) +/// rather than the initial data load. +struct MobileAutopilotLoadingDialog: View { + var title: LocalizedStringKey = "Working…" + var message: LocalizedStringKey? + + var body: some View { + ZStack { + Color.black.opacity(0.25) + .ignoresSafeArea() + VStack(spacing: 14) { + ProgressView() + .controlSize(.large) + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + if let message { + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(24) + .frame(maxWidth: 280) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20)) + .shadow(radius: 20) + } + .transition(.opacity) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(title)) + } +} + +extension View { + /// Overlays an alert-style loading dialog (dimmed scrim + centered card) + /// while `isLoading` is true. Use for blocking *actions* like creating a PR. + @ViewBuilder + func mobileAutopilotLoadingDialog( + _ isLoading: Bool, + title: LocalizedStringKey = "Working…", + message: LocalizedStringKey? = nil + ) -> some View { + overlay { + if isLoading { + MobileAutopilotLoadingDialog(title: title, message: message) + } + } + .animation(.easeInOut(duration: 0.2), value: isLoading) + } +} diff --git a/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift index 2250bc3c..8b5d0604 100644 --- a/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift +++ b/RxCodeMobile/Views/Autopilot/MobileSecretsView.swift @@ -15,6 +15,7 @@ struct MobileSecretsView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var searchTask: Task<Void, Never>? + @State private var hasAppeared = false var body: some View { List { @@ -75,7 +76,16 @@ struct MobileSecretsView: View { } .mobileAutopilotLoadingOverlay(enrolled == nil && isLoading) .refreshable { await load() } - .task { await load() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await load() + } + .onAppear { + // Always refresh when returning to this screen (the very first + // appear is handled by `.task` above). + if hasAppeared { Task { await load() } } + } } private func load() async { @@ -110,6 +120,9 @@ struct MobileSecretsRepoView: View { @State private var showingAdd = false @State private var newName = "" @State private var pendingDelete: SecretsEnvironment? + @State private var isMutating = false + @State private var mutatingTitle: LocalizedStringKey = "Working…" + @State private var hasAppeared = false var body: some View { List { @@ -164,8 +177,18 @@ struct MobileSecretsRepoView: View { Text("This permanently deletes the environment and its secret files.") } .mobileAutopilotLoadingOverlay(isLoading && environments.isEmpty) + .mobileAutopilotLoadingDialog(isMutating, title: mutatingTitle) .refreshable { await reload() } - .task { await reload() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await reload() + } + .onAppear { + // Always refresh when returning to this screen (e.g. after editing + // files in a child environment changes the file counts). + if hasAppeared { Task { await reload() } } + } } private func reload() async { @@ -182,6 +205,9 @@ struct MobileSecretsRepoView: View { private func create() async { let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } + mutatingTitle = "Creating environment…" + isMutating = true + defer { isMutating = false } do { try await state.createSecretEnvironment(repo: repoFullName, name: name) await reload() @@ -191,6 +217,9 @@ struct MobileSecretsRepoView: View { } private func delete(_ env: SecretsEnvironment) async { + mutatingTitle = "Deleting environment…" + isMutating = true + defer { isMutating = false } do { try await state.deleteSecretEnvironment(repo: repoFullName, envId: env.id) await reload() @@ -213,6 +242,8 @@ struct MobileSecretsEnvironmentView: View { @State private var errorMessage: String? @State private var editingFile: EditingFile? @State private var pendingDelete: SecretsFileMeta? + @State private var isMutating = false + @State private var hasAppeared = false struct EditingFile: Identifiable { let id = UUID() @@ -287,8 +318,17 @@ struct MobileSecretsEnvironmentView: View { } } .mobileAutopilotLoadingOverlay(isLoading && files.isEmpty) + .mobileAutopilotLoadingDialog(isMutating, title: "Deleting file…") .refreshable { await reload() } - .task { await reload() } + .task { + guard !hasAppeared else { return } + hasAppeared = true + await reload() + } + .onAppear { + // Always refresh when returning to this screen. + if hasAppeared { Task { await reload() } } + } } private func reload() async { @@ -333,6 +373,8 @@ struct MobileSecretsEnvironmentView: View { } private func delete(_ file: SecretsFileMeta) async { + isMutating = true + defer { isMutating = false } do { try await state.deleteSecretFile(repo: repoFullName, envId: environment.id, fileId: file.id) await reload() @@ -400,6 +442,7 @@ struct MobileSecretFileEditor: View { .disabled(isSaving || filename.trimmingCharacters(in: .whitespaces).isEmpty) } } + .mobileAutopilotLoadingDialog(isSaving, title: file.isNew ? "Creating file…" : "Saving file…") } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index cd401965..1b324cfb 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -119,7 +119,11 @@ struct MobileBriefingDetailView: View { "branch": groupKey.branch, ]) } - .mobileAutopilotLoadingOverlay(isCreatingPR, title: "Creating Pull Request…") + .mobileAutopilotLoadingDialog( + isCreatingPR, + title: "Creating Pull Request…", + message: "The Mac is pushing the branch and opening the PR." + ) } private var group: GroupedBriefing? { From 0055ea137f48dbd4853c541946dca9c063ef5bc5 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:41:07 +0800 Subject: [PATCH 4/4] feat(git): refactor SettingsScreen.kt to improve layout for better readability --- .../main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 29ddda59..379446fb 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 @@ -364,13 +364,12 @@ private fun PairedDesktopCard( shape = CircleShape, color = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(40.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( Icons.Outlined.CheckCircle, contentDescription = "Active", - modifier = Modifier.size(20.dp), ) } }