From 1a9e1018a51c522fa95734c3298ded2e4644fb32 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 25 May 2026 16:34:28 +0800 Subject: [PATCH 1/3] fix: switching branch in new thread sheet error --- RxCode/App/AppState+MobileSync.swift | 6 ++- RxCode/App/AppState+Worktree.swift | 44 +++++++++++++++++++ .../State/MobileAppState+Intents.swift | 16 +++++++ RxCodeMobile/Views/NewThreadSheet.swift | 20 ++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift index 0ff1d110..bc0aa946 100644 --- a/RxCode/App/AppState+MobileSync.swift +++ b/RxCode/App/AppState+MobileSync.swift @@ -538,7 +538,11 @@ extension AppState { do { switch request.operation { case .switchExisting: - try await switchToExistingBranch(trimmed, in: window) + // Mobile's new-thread sheet expects each thread to spawn into + // its own worktree — never `git checkout` against the project + // root, since that would silently move main onto the picked + // branch and skip the worktree entirely. + try await attachWorktreeForExistingBranch(trimmed, in: window) updateMobilePendingWorktree(from: window, projectID: project.id) case .createNew: try await attachWorktree(branch: trimmed, in: window) diff --git a/RxCode/App/AppState+Worktree.swift b/RxCode/App/AppState+Worktree.swift index b3c81a09..10830020 100644 --- a/RxCode/App/AppState+Worktree.swift +++ b/RxCode/App/AppState+Worktree.swift @@ -186,6 +186,50 @@ extension AppState { } } + /// Mobile new-thread flow: spawn the next thread on `branch` via a Git + /// worktree, not by mutating the main project repo. Reuses an existing + /// linked worktree for the branch when one is around; otherwise creates a + /// fresh worktree off the existing branch. Falls back to leaving the + /// pending pointer nil only when the branch is already checked out in the + /// main repo (`git worktree add` would refuse), so the thread still spawns + /// on the right branch without misrepresenting the project root as a + /// worktree. + func attachWorktreeForExistingBranch(_ branch: String, in window: WindowState) async throws { + guard let project = window.selectedProject else { + throw AppError.noProjectSelected + } + let baseRepo = URL(fileURLWithPath: project.path) + + let info = try await GitWorktreeService.shared.createWorktree( + baseRepo: baseRepo, + branch: branch + ) + + let isMainRepo = info.path.standardizedFileURL == baseRepo.standardizedFileURL + let newPath: String? = isMainRepo ? nil : info.path.path + let newBranch: String? = isMainRepo ? nil : info.branch + + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = newPath + window.pendingWorktreeBranch = newBranch + return + } + + sessionStates[sessionId, default: SessionStreamState()].worktreePath = newPath + sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = newBranch + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = newPath + allSessionSummaries[idx].worktreeBranch = newBranch + threadStore.upsert(allSessionSummaries[idx]) + } + if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { + await updateSessionMetadata(snap.makeSession()) { s in + s.worktreePath = newPath + s.worktreeBranch = newBranch + } + } + } + /// Switch the chat to an existing branch. /// /// If the branch is already attached to a linked worktree, point the diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift index e9ad6d72..3652a0e9 100644 --- a/RxCodeMobile/State/MobileAppState+Intents.swift +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -323,6 +323,22 @@ extension MobileAppState { lastBranchOpError = nil } + /// Block until every in-flight branch op has been acknowledged by the + /// desktop (or the timeout elapses). The new-thread sheet calls this + /// before firing `requestNewSession` so the desktop has finished parking + /// the picked branch's worktree into `mobilePendingWorktrees` before the + /// session spawns — otherwise the two payloads race and the new thread + /// lands at the project root instead of on the picked branch. + func waitForPendingBranchOps(timeout: Duration = .seconds(5)) async { + guard !inFlightBranchOps.isEmpty else { return } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !inFlightBranchOps.isEmpty { + if clock.now >= deadline { return } + try? await Task.sleep(for: .milliseconds(50)) + } + } + func subscribe(to sessionID: String?) async { let sessionID = sessionID.map(resolveSessionID) activeSessionID = sessionID diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index aaec7e60..9588f5fa 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -209,6 +209,11 @@ struct NewThreadSheet: View { ) UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { + // Make sure the desktop has parked the picked branch's worktree + // before the new session spawns; otherwise the two payloads race + // and the thread lands on the project root instead of the + // selected branch's worktree. + await state.waitForPendingBranchOps() await state.requestNewSession( projectID: projectID, initialText: body, @@ -249,6 +254,12 @@ struct NewThreadConfigStrip: View { @Binding var planModeEnabled: Bool @State private var showingCreateBranch = false @State private var didApplyPreferredBranch = false + /// Optimistically tracks the branch the user picked in this sheet so the + /// chip label updates immediately. The desktop's `projectBranches` snapshot + /// only reflects the main repo's branch, which doesn't change when the + /// thread is parked on a worktree — without this local override the chip + /// would stay stuck on the initial branch. + @State private var pickedBranch: String? private let columns = [ GridItem(.flexible(), spacing: 12), @@ -285,6 +296,11 @@ struct NewThreadConfigStrip: View { .onChange(of: state.projectBranches[projectID]) { _, _ in applyPreferredBranchIfNeeded() } + .onChange(of: state.lastBranchOpError) { _, newValue in + // Revert the optimistic pick so the chip falls back to the + // desktop's actual current branch when the op failed. + if newValue != nil { pickedBranch = nil } + } .alert( "Branch operation failed", isPresented: branchOpErrorBinding, @@ -311,7 +327,7 @@ struct NewThreadConfigStrip: View { } private var currentBranch: String? { - state.projectBranches[projectID] ?? preferredBranch + pickedBranch ?? state.projectBranches[projectID] ?? preferredBranch } /// When the sheet was opened from a context that knows the desired branch @@ -326,6 +342,7 @@ struct NewThreadConfigStrip: View { return } didApplyPreferredBranch = true + pickedBranch = target Task { await state.switchProjectBranch(projectID: projectID, branch: target) } } @@ -335,6 +352,7 @@ struct NewThreadConfigStrip: View { Section("Switch branch") { ForEach(branchList, id: \.self) { name in Button { + pickedBranch = name Task { await state.switchProjectBranch(projectID: projectID, branch: name) } } label: { HStack { From 0c71d5a9c0216c966b3b9eda082a4b568d9ea8a9 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Mon, 25 May 2026 16:34:41 +0800 Subject: [PATCH 2/3] feat: add android app support --- .github/workflows/test.yaml | 29 + .../Sources/MessageList/MessageList.swift | 15 +- .../RxCodeChatKit/TodoProgressView.swift | 2 +- .../Sources/RxCodeSync/Protocol/Payload.swift | 15 +- RxCode/App/AppState+CrossProject.swift | 13 + RxCode/App/AppState+Messaging.swift | 12 +- RxCode/App/AppState+MobileRemote.swift | 7 + RxCode/App/AppState+MobileSnapshots.swift | 3 +- RxCode/App/AppState+Stream.swift | 88 ++ RxCode/App/AppState.swift | 23 + RxCode/Resources/Localizable.xcstrings | 9 + RxCode/Services/ClaudeService+Process.swift | 35 +- .../Chat/RecentChatsSuggestionList.swift | 33 +- RxCode/Views/Sidebar/HistoryListView.swift | 33 +- RxCode/Views/Sidebar/ProjectTreeView.swift | 31 +- RxCodeAndroid/.gitignore | 15 + RxCodeAndroid/.idea/.gitignore | 3 + RxCodeAndroid/.idea/AndroidProjectSystem.xml | 6 + RxCodeAndroid/.idea/compiler.xml | 6 + .../.idea/deploymentTargetSelector.xml | 10 + RxCodeAndroid/.idea/gradle.xml | 19 + RxCodeAndroid/.idea/migrations.xml | 10 + RxCodeAndroid/.idea/misc.xml | 10 + RxCodeAndroid/.idea/runConfigurations.xml | 17 + RxCodeAndroid/.idea/vcs.xml | 6 + .../.kotlin/errors/errors-1779702003413.log | 54 + .../.kotlin/errors/errors-1779703098971.log | 52 + .../.kotlin/errors/errors-1779703100049.log | 58 + RxCodeAndroid/app/.gitignore | 1 + RxCodeAndroid/app/build.gradle.kts | 98 ++ RxCodeAndroid/app/proguard-rules.pro | 21 + .../app/src/debug/AndroidManifest.xml | 6 + .../res/xml/debug_network_security_config.xml | 4 + .../app/src/main/AndroidManifest.xml | 54 + .../java/app/rxlab/rxcode/MainActivity.kt | 81 ++ .../app/rxlab/rxcode/RxCodeApplication.kt | 17 + .../main/java/app/rxlab/rxcode/crypto/Hex.kt | 28 + .../app/rxlab/rxcode/crypto/SessionCrypto.kt | 178 +++ .../java/app/rxlab/rxcode/di/AppModule.kt | 29 + .../rxlab/rxcode/identity/DeviceIdentity.kt | 58 + .../app/rxlab/rxcode/pairing/PairingToken.kt | 59 + .../java/app/rxlab/rxcode/proto/Envelope.kt | 76 + .../main/java/app/rxlab/rxcode/proto/Json.kt | 78 ++ .../java/app/rxlab/rxcode/proto/Models.kt | 147 ++ .../java/app/rxlab/rxcode/proto/Payload.kt | 540 ++++++++ .../java/app/rxlab/rxcode/proto/RunProfile.kt | 169 +++ .../java/app/rxlab/rxcode/proto/TodoItem.kt | 63 + .../app/rxlab/rxcode/relay/RelayClient.kt | 192 +++ .../app/rxlab/rxcode/state/MobileAppState.kt | 769 +++++++++++ .../app/rxlab/rxcode/state/MobileState.kt | 116 ++ .../app/rxlab/rxcode/store/PairedDesktop.kt | 26 + .../app/rxlab/rxcode/store/PairingStore.kt | 75 + .../java/app/rxlab/rxcode/sync/SyncClient.kt | 122 ++ .../java/app/rxlab/rxcode/ui/RxCodeApp.kt | 134 ++ .../ui/briefing/BriefingDetailScreen.kt | 469 +++++++ .../rxcode/ui/briefing/BriefingGrouping.kt | 70 + .../rxlab/rxcode/ui/briefing/BriefingPane.kt | 381 +++++ .../rxcode/ui/briefing/BriefingScreen.kt | 467 +++++++ .../rxcode/ui/browser/BrowserUrlDetector.kt | 59 + .../rxcode/ui/browser/InAppBrowserScreen.kt | 291 ++++ .../app/rxlab/rxcode/ui/chat/ChatScreen.kt | 1229 +++++++++++++++++ .../app/rxlab/rxcode/ui/chat/EditPreview.kt | 159 +++ .../rxcode/ui/onboarding/OnboardingScreen.kt | 743 ++++++++++ .../rxlab/rxcode/ui/projects/ProjectsPane.kt | 398 ++++++ .../rxcode/ui/projects/ProjectsScreen.kt | 240 ++++ .../rxcode/ui/sessions/SessionsScreen.kt | 435 ++++++ .../rxcode/ui/settings/SettingsScreen.kt | 408 ++++++ .../rxlab/rxcode/ui/sheets/FileDiffSheet.kt | 277 ++++ .../rxlab/rxcode/ui/sheets/NewThreadSheet.kt | 235 ++++ .../ui/sheets/PermissionApprovalSheet.kt | 144 ++ .../rxlab/rxcode/ui/sheets/QuestionSheet.kt | 190 +++ .../rxcode/ui/sheets/QueuedMessagesSheet.kt | 94 ++ .../rxcode/ui/sheets/RenameThreadSheet.kt | 70 + .../rxcode/ui/sheets/RunProfileEditorSheet.kt | 209 +++ .../rxcode/ui/sheets/RunProfilesSheet.kt | 339 +++++ .../rxcode/ui/sheets/ThreadChangesSheet.kt | 503 +++++++ .../rxlab/rxcode/ui/sheets/ThreadTodoSheet.kt | 249 ++++ .../rxlab/rxcode/ui/sheets/ToolCallSheet.kt | 138 ++ .../rxlab/rxcode/ui/sync/SyncLoadingView.kt | 243 ++++ .../java/app/rxlab/rxcode/ui/theme/Theme.kt | 83 ++ .../java/app/rxlab/rxcode/ui/util/Haptics.kt | 56 + .../rxlab/rxcode/ui/util/KeyboardDismiss.kt | 26 + .../java/app/rxlab/rxcode/ui/util/Markdown.kt | 39 + .../java/app/rxlab/rxcode/ui/util/Time.kt | 38 + .../res/drawable/ic_launcher_background.xml | 9 + .../res/drawable/ic_launcher_foreground.xml | 12 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1290 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 8248 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 1432 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 780 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 4913 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 874 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1772 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 12190 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 1962 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2910 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 21895 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 3196 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 4226 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 34216 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 4634 bytes .../app/src/main/res/values-night/themes.xml | 6 + .../app/src/main/res/values/colors.xml | 7 + .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/themes.xml | 6 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + RxCodeAndroid/build.gradle.kts | 8 + RxCodeAndroid/gradle.properties | 23 + RxCodeAndroid/gradle/libs.versions.toml | 86 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + RxCodeAndroid/gradlew | 185 +++ RxCodeAndroid/gradlew.bat | 89 ++ RxCodeAndroid/settings.gradle.kts | 27 + .../State/MobileAppState+Inbound.swift | 35 +- .../State/MobileAppState+Persistence.swift | 12 +- RxCodeMobile/State/MobileAppState.swift | 11 + RxCodeMobile/Views/ThreadTodoSheet.swift | 2 +- 121 files changed, 12577 insertions(+), 31 deletions(-) create mode 100644 RxCodeAndroid/.gitignore create mode 100644 RxCodeAndroid/.idea/.gitignore create mode 100644 RxCodeAndroid/.idea/AndroidProjectSystem.xml create mode 100644 RxCodeAndroid/.idea/compiler.xml create mode 100644 RxCodeAndroid/.idea/deploymentTargetSelector.xml create mode 100644 RxCodeAndroid/.idea/gradle.xml create mode 100644 RxCodeAndroid/.idea/migrations.xml create mode 100644 RxCodeAndroid/.idea/misc.xml create mode 100644 RxCodeAndroid/.idea/runConfigurations.xml create mode 100644 RxCodeAndroid/.idea/vcs.xml create mode 100644 RxCodeAndroid/.kotlin/errors/errors-1779702003413.log create mode 100644 RxCodeAndroid/.kotlin/errors/errors-1779703098971.log create mode 100644 RxCodeAndroid/.kotlin/errors/errors-1779703100049.log create mode 100644 RxCodeAndroid/app/.gitignore create mode 100644 RxCodeAndroid/app/build.gradle.kts create mode 100644 RxCodeAndroid/app/proguard-rules.pro create mode 100644 RxCodeAndroid/app/src/debug/AndroidManifest.xml create mode 100644 RxCodeAndroid/app/src/debug/res/xml/debug_network_security_config.xml create mode 100644 RxCodeAndroid/app/src/main/AndroidManifest.xml create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/Hex.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SessionCrypto.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/di/AppModule.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/identity/DeviceIdentity.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/pairing/PairingToken.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Envelope.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/RunProfile.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/TodoItem.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/relay/RelayClient.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairingStore.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/sync/SyncClient.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/BrowserUrlDetector.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/InAppBrowserScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/EditPreview.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/FileDiffSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/PermissionApprovalSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/QuestionSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/QueuedMessagesSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/RenameThreadSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/RunProfileEditorSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/RunProfilesSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ThreadChangesSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ThreadTodoSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/ToolCallSheet.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sync/SyncLoadingView.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/theme/Theme.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/Haptics.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/KeyboardDismiss.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/Markdown.kt create mode 100644 RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/util/Time.kt create mode 100644 RxCodeAndroid/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 RxCodeAndroid/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 RxCodeAndroid/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 RxCodeAndroid/app/src/main/res/values-night/themes.xml create mode 100644 RxCodeAndroid/app/src/main/res/values/colors.xml create mode 100644 RxCodeAndroid/app/src/main/res/values/strings.xml create mode 100644 RxCodeAndroid/app/src/main/res/values/themes.xml create mode 100644 RxCodeAndroid/app/src/main/res/xml/backup_rules.xml create mode 100644 RxCodeAndroid/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 RxCodeAndroid/build.gradle.kts create mode 100644 RxCodeAndroid/gradle.properties create mode 100644 RxCodeAndroid/gradle/libs.versions.toml create mode 100644 RxCodeAndroid/gradle/wrapper/gradle-wrapper.jar create mode 100644 RxCodeAndroid/gradle/wrapper/gradle-wrapper.properties create mode 100755 RxCodeAndroid/gradlew create mode 100644 RxCodeAndroid/gradlew.bat create mode 100644 RxCodeAndroid/settings.gradle.kts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1b60c6b8..3897f3c8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -216,3 +216,32 @@ jobs: name: mobile-ui-test-results-${{ matrix.label }} path: TestResults-${{ matrix.label }}.xcresult retention-days: 14 + + build-android: + name: gradle build (RxCodeAndroid) + runs-on: blacksmith-4vcpu-ubuntu-2404 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: useblacksmith/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Setup Gradle + uses: useblacksmith/setup-gradle@v6 + + - name: Assemble debug APK + working-directory: RxCodeAndroid + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + if: always() + uses: actions/upload-artifact@v4 + with: + name: RxCodeAndroid-debug-apk + path: RxCodeAndroid/app/build/outputs/apk/debug/*.apk + retention-days: 7 diff --git a/Packages/Sources/MessageList/MessageList.swift b/Packages/Sources/MessageList/MessageList.swift index 5e0cd5ce..3c4ede91 100644 --- a/Packages/Sources/MessageList/MessageList.swift +++ b/Packages/Sources/MessageList/MessageList.swift @@ -42,6 +42,8 @@ public struct MessageList: View { @State private var isLoadingNext = false @State private var previousLoadContentHeight: CGFloat? @State private var nextLoadContentHeight: CGFloat? + @State private var previousLoadCooldownUntil: Date = .distantPast + @State private var nextLoadCooldownUntil: Date = .distantPast public init( messages: [Message], @@ -467,6 +469,7 @@ public struct MessageList: View { private func triggerLoadPreviousIfNeeded(contentHeight: CGFloat) { guard !isLoadingPrevious, + Date() >= previousLoadCooldownUntil, hasMorePrevious(), let loadMorePrevious, previousLoadContentHeight != contentHeight @@ -475,7 +478,10 @@ public struct MessageList: View { previousLoadContentHeight = contentHeight isLoadingPrevious = true Task { @MainActor in - defer { isLoadingPrevious = false } + defer { + previousLoadCooldownUntil = Date().addingTimeInterval(MessageListConstants.loadMoreCooldownSeconds) + isLoadingPrevious = false + } do { try await loadMorePrevious() } catch { @@ -486,6 +492,7 @@ public struct MessageList: View { private func triggerLoadNextIfNeeded(contentHeight: CGFloat) { guard !isLoadingNext, + Date() >= nextLoadCooldownUntil, hasMore(), let loadMore, nextLoadContentHeight != contentHeight @@ -494,7 +501,10 @@ public struct MessageList: View { nextLoadContentHeight = contentHeight isLoadingNext = true Task { @MainActor in - defer { isLoadingNext = false } + defer { + nextLoadCooldownUntil = Date().addingTimeInterval(MessageListConstants.loadMoreCooldownSeconds) + isLoadingNext = false + } do { try await loadMore() } catch { @@ -525,6 +535,7 @@ private nonisolated enum MessageListConstants { static let userScrollDownDelta: CGFloat = 4 static let layoutSettleDelayNanoseconds: UInt64 = 16_000_000 static let streamingBottomScrollInterval: TimeInterval = 2 + static let loadMoreCooldownSeconds: TimeInterval = 1 static let scrollAnimationSeconds: Double = 0.24 static let pinAnimationDuration: Duration = .milliseconds(320) static let pinAnimationSeconds: Double = 0.32 diff --git a/Packages/Sources/RxCodeChatKit/TodoProgressView.swift b/Packages/Sources/RxCodeChatKit/TodoProgressView.swift index 5db799f5..b82de0a2 100644 --- a/Packages/Sources/RxCodeChatKit/TodoProgressView.swift +++ b/Packages/Sources/RxCodeChatKit/TodoProgressView.swift @@ -83,7 +83,7 @@ public struct TodoListPopoverView: View { .padding(12) } else { ScrollView { - VStack(alignment: .leading, spacing: 6) { + LazyVStack(alignment: .leading, spacing: 6) { ForEach(todos) { todo in row(todo) } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 292df65a..15efaec7 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -345,6 +345,14 @@ public struct SnapshotPayload: Codable, Sendable { /// HTTP proxy exposed by the desktop so the mobile in-app browser can open /// localhost development servers running on the Mac. public let webProxy: MobileWebProxyInfo? + /// Monotonically-increasing sequence number stamped by the desktop on every + /// outgoing snapshot. Mobile drops snapshots whose `seq` is not strictly + /// greater than the last one it applied, so a delayed snapshot in flight + /// at the moment a fresher one is sent cannot reset + /// `activeSessionMessages` to a stale page and "lose" recently-streamed + /// text. `nil` when the desktop predates snapshot sequencing — mobile + /// applies those as before. + public let seq: UInt64? public init( projects: [Project], sessions: [SessionSummary], @@ -359,7 +367,8 @@ public struct SnapshotPayload: Codable, Sendable { hostMetrics: HostMetricsSnapshot? = nil, runProfiles: [MobileProjectRunProfiles]? = nil, runTasks: [MobileRunTaskSnapshot]? = nil, - webProxy: MobileWebProxyInfo? = nil + webProxy: MobileWebProxyInfo? = nil, + seq: UInt64? = nil ) { self.projects = projects self.sessions = sessions @@ -375,12 +384,13 @@ public struct SnapshotPayload: Codable, Sendable { self.runProfiles = runProfiles self.runTasks = runTasks self.webProxy = webProxy + self.seq = seq } private enum CodingKeys: String, CodingKey { case projects, sessions, branchBriefings, threadSummaries, settings case activeSessionID, activeSessionMessages, activeSessionHasMore, projectBranches - case usage, hostMetrics, runProfiles, runTasks, webProxy + case usage, hostMetrics, runProfiles, runTasks, webProxy, seq } public init(from decoder: Decoder) throws { @@ -399,6 +409,7 @@ public struct SnapshotPayload: Codable, Sendable { runProfiles = try c.decodeIfPresent([MobileProjectRunProfiles].self, forKey: .runProfiles) runTasks = try c.decodeIfPresent([MobileRunTaskSnapshot].self, forKey: .runTasks) webProxy = try c.decodeIfPresent(MobileWebProxyInfo.self, forKey: .webProxy) + seq = try c.decodeIfPresent(UInt64.self, forKey: .seq) } } diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index a84f6796..9c18a538 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -381,6 +381,18 @@ extension AppState { startFlushTimer(for: sessionKey) + // Reset the watchdog clock at the start so the first event window is + // measured from when we actually began awaiting the stream, not from + // an earlier turn that was finalized on this same session state. + updateState(sessionKey) { $0.lastStreamEventDate = Date() } + let watchdogTask = startStreamWatchdog( + streamId: streamId, + sessionKey: sessionKey, + agentProvider: agentProvider, + in: window + ) + defer { watchdogTask.cancel() } + var eventCount = 0 var lastEventTime = Date() @@ -389,6 +401,7 @@ extension AppState { eventCount += 1 let gap = Date().timeIntervalSince(lastEventTime) lastEventTime = Date() + updateState(sessionKey) { $0.lastStreamEventDate = lastEventTime } guard !Task.isCancelled else { logger.info("[Stream:UI] task cancelled after \(eventCount) events") diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 08d6bc78..ff3b6add 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -255,9 +255,16 @@ extension AppState { let sessionKey = window.currentSessionId! - // Apply initialMessages if provided - if let initial = initialMessages { + // Apply initialMessages if provided. Refuse to clobber an already- + // populated in-memory list with an empty array — `editAndResend` is + // the only documented caller and always supplies a non-empty truncated + // history, so an empty `initial` would be a regression that erases + // the user's chat. + if let initial = initialMessages, !initial.isEmpty { updateState(sessionKey) { $0.messages = initial } + } else if let initial = initialMessages, initial.isEmpty { + let existing = sessionStates[sessionKey]?.messages.count ?? 0 + logger.error("[sendPrompt] refusing to overwrite \(existing) messages with empty initialMessages for session=\(sessionKey, privacy: .public)") } let wasFirstUserMessage = (sessionStates[sessionKey]?.messages.filter { $0.role == .user }.count ?? 0) == 0 @@ -436,6 +443,7 @@ extension AppState { state.activeToolInputBuffer = "" state.textDeltaBuffer = "" state.pendingToolResults.removeAll() + state.lastStreamEventDate = nil extraMutations?(&state) diff --git a/RxCode/App/AppState+MobileRemote.swift b/RxCode/App/AppState+MobileRemote.swift index 7ce5505d..a0ce4698 100644 --- a/RxCode/App/AppState+MobileRemote.swift +++ b/RxCode/App/AppState+MobileRemote.swift @@ -707,6 +707,13 @@ extension AppState { if sessionStates[summary.id] == nil, let full = await persistence.loadFullSession(summary: summary, cwd: project.path) { updateState(summary.id) { state in + // Skip the assignment when persistence returned an empty thread + // (e.g. corrupted JSONL that decoded to no messages). The state + // we just created is also empty, so this is a no-op cosmetic + // guard — but it stops a future caller from accidentally + // overwriting a non-empty in-memory list if this branch is + // ever reached for a session that re-appeared mid-call. + guard !full.messages.isEmpty || state.messages.isEmpty else { return } state.messages = full.messages } } diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 420a4378..b1f9c12f 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -151,7 +151,8 @@ extension AppState { hostMetrics: hostMetrics, runProfiles: runProfiles, runTasks: runTasks, - webProxy: webProxy + webProxy: webProxy, + seq: nextMobileSnapshotSeq() ) await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) // The snapshot doesn't carry the question queue; send it alongside so a diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index 844421ff..2ca7433f 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -6,6 +6,93 @@ import RxCodeSync import SwiftUI extension AppState { + // MARK: - Stream Inactivity Watchdog + + /// Wall-clock seconds of stream silence after which the watchdog decides + /// the agent is wedged and force-cleans the session. Sized to cover long + /// legitimate gaps — model thinking, a slow `Bash` tool, an LLM-side + /// rate-limit retry — without being so generous that a truly stuck thread + /// stays stuck for the rest of the user's session. + static let streamInactivityTimeout: TimeInterval = 600 + + /// How often the watchdog wakes up to recompute the gap. Short enough that + /// the unstick fires within a few seconds of the threshold without burning + /// CPU when the stream is healthy. + static let streamInactivityPollInterval: UInt64 = 15 * 1_000_000_000 + + /// Spawn a background poller that unblocks the session when the agent goes + /// silent without finishing. Without this, a dead/wedged CLI would leave + /// `isStreaming`/`activeStreamId` set forever — every subsequent send into + /// the same thread would no-op silently, which is why users had to start a + /// brand new thread to "fix" a stuck one. + /// + /// Self-cancels when the session is no longer streaming, when ownership + /// has moved to another stream, or when no events have been recorded yet. + /// Otherwise on threshold-cross it cancels the backend (so the CLI process + /// gets SIGINT/SIGKILL), cancels the stream task (so `processStream`'s + /// `for await` unblocks), force-finalizes session state, and surfaces an + /// error bubble in the foreground window if the user is looking at it. + @MainActor + func startStreamWatchdog( + streamId: UUID, + sessionKey initialKey: String, + agentProvider: AgentProvider, + in window: WindowState + ) -> Task { + Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.streamInactivityPollInterval) + guard !Task.isCancelled else { return } + guard let self else { return } + + // Resolve the live session key — the CLI may have rotated + // session_id mid-stream (compact_boundary, pending → real + // promotion), so the key the watchdog was spawned with may + // have been renamed under it. + let currentKey = self.sessionIdRedirect[initialKey] ?? initialKey + let state = self.stateForSession(currentKey) + + guard state.isStreaming, state.activeStreamId == streamId else { return } + guard let last = state.lastStreamEventDate else { continue } + + let gap = Date().timeIntervalSince(last) + guard gap >= Self.streamInactivityTimeout else { continue } + + self.logger.error("[Stream:Watchdog] no events for \(Int(gap))s on session=\(currentKey, privacy: .public) stream=\(streamId) — force-cleaning") + + // Cancel the backend first so the CLI process group gets + // SIGINT/SIGKILL and stops emitting more events into the + // stream we're about to abandon. + await self.backend(for: agentProvider).cancel(streamId: streamId) + + self.sessionStates[currentKey]?.streamTask?.cancel() + + let message = "Agent stopped responding after \(Int(gap))s of silence. The session was reset — send your message again to continue." + + // Inject the error bubble (and trip the unread flag for + // backgrounded sessions) inside the same finalize so the + // mobile diff broadcast carries both at once. + let isForeground = (window.currentSessionId ?? window.newSessionKey) == currentKey + self.finalizeStreamSession(for: currentKey) { state in + if !isForeground { state.hasUncheckedCompletion = true } + state.messages.append(ChatMessage(role: .assistant, content: message, isError: true)) + } + if isForeground { + window.errorMessage = message + window.showError = true + } + + self.recordStreamCompletion( + streamId: streamId, + sessionId: currentKey, + assistantText: "", + error: message + ) + return + } + } + } + // MARK: - Text Delta Grouping func startFlushTimer(for sessionKey: String) { @@ -350,6 +437,7 @@ extension AppState { state.activeToolInputBuffer = "" state.textDeltaBuffer = "" state.pendingToolResults.removeAll() + state.lastStreamEventDate = nil if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 72ea22e7..9a553115 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -34,6 +34,14 @@ struct SessionStreamState { var streamTask: Task? var hasUncheckedCompletion = false + /// Last time any stream event (system / assistant / tool result / etc.) arrived + /// for the active stream. Updated in `processStream` and polled by the + /// inactivity watchdog so a CLI that goes silent without exiting (broken pipe + /// not surfaced, MCP child wedged, etc.) gets force-cleaned instead of + /// leaving `isStreaming`/`activeStreamId` stuck — which would block every + /// subsequent send into the thread. + var lastStreamEventDate: Date? + // Text delta buffer (10-token grouped flush) var textDeltaBuffer = "" var pendingToolResults: [(toolUseId: String, content: String, isError: Bool)] = [] @@ -957,6 +965,21 @@ final class AppState { /// Last seen jsonl byte size per session — used as a cheap drift signal /// in `reconcileFromDisk` so the no-drift path skips the full mmap+parse. var lastReconciledJsonlSize: [String: UInt64] = [:] + + /// Monotonic counter stamped into every outgoing `SnapshotPayload.seq`. + /// Lets mobile reject snapshots that arrive out of order — a fresher one + /// sent right after an older one is no longer at risk of being clobbered + /// when the older one finally lands on the relay. Starts at `1` so any + /// non-zero value distinguishes "stamped by a sequencing-aware desktop" + /// from the wire default (`nil`). + @ObservationIgnored + var mobileSnapshotSeq: UInt64 = 0 + + func nextMobileSnapshotSeq() -> UInt64 { + mobileSnapshotSeq &+= 1 + if mobileSnapshotSeq == 0 { mobileSnapshotSeq = 1 } // skip wraparound zero + return mobileSnapshotSeq + } } // MARK: - App Errors diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index fbc66155..7f552e31 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -50,6 +50,9 @@ } } } + }, + "\"%@\" will be archived. Archived chats are hidden from the active list and can be restored from Archived." : { + }, "\"%@\" will be deleted. This action cannot be undone." : { "localizations" : { @@ -1042,6 +1045,9 @@ } } } + }, + "Archive Chat" : { + }, "archived" : { "localizations" : { @@ -6786,6 +6792,9 @@ } } } + }, + "This chat will be archived. Archived chats are hidden from the active list and can be restored from Archived." : { + }, "This memory will be removed from future agent context." : { "localizations" : { diff --git a/RxCode/Services/ClaudeService+Process.swift b/RxCode/Services/ClaudeService+Process.swift index a40a7471..230f99f4 100644 --- a/RxCode/Services/ClaudeService+Process.swift +++ b/RxCode/Services/ClaudeService+Process.swift @@ -69,7 +69,32 @@ extension ClaudeCodeServer { } ) } catch { + // Distinguish "could not launch the CLI" (binary missing, + // posix_spawn failed) from "launched but stdin write to + // send the user prompt failed" (CLI exited immediately, + // broken pipe). Both end the stream, but the latter is a + // legitimate runtime hang surfaced through `.result` so + // the UI shows a real error bubble instead of a generic + // "No response received". log.error("[Stream] spawn failed: \(error.localizedDescription)") + let description: String + if case ClaudeError.spawnFailed(let msg) = error { + description = "Failed to start Claude CLI: \(msg)" + } else if case ClaudeError.binaryNotFound = error { + description = "Claude CLI binary not found." + } else { + description = "Failed to send to Claude CLI: \(error.localizedDescription)" + } + continuation.yield(.user(UserMessage( + toolUseId: nil, + content: description, + isError: true + ))) + continuation.yield(.result(ResultEvent( + durationMs: nil, totalCostUsd: nil, + sessionId: sessionId ?? streamId.uuidString, + isError: true, totalTurns: nil, usage: nil, contextWindow: nil + ))) continuation.finish() return } @@ -625,10 +650,16 @@ extension ClaudeCodeServer { /// Serialize a dictionary to JSON and write to stdin as one NDJSON line. /// Non-isolated to allow use from `spawnProcess` after `try proc.run()`. + /// + /// Uses the Swift-throwing `write(contentsOf:)` rather than the legacy + /// `write(_:)` — the latter raises an Objective-C `NSFileHandleOperationException` + /// on broken-pipe (e.g. CLI already died before we sent the user message), + /// which Swift cannot catch and would terminate the app instead of letting + /// the caller surface a real error to the user. static func writeJSONLine(_ object: [String: Any], to handle: FileHandle) throws { let data = try JSONSerialization.data(withJSONObject: object, options: []) - handle.write(data) - handle.write(Data([0x0A])) // newline + try handle.write(contentsOf: data) + try handle.write(contentsOf: Data([0x0A])) // newline } /// Close stdin for an active stream. Call this after receiving the `result` event diff --git a/RxCode/Views/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index 4acccbcf..32561c72 100644 --- a/RxCode/Views/Chat/RecentChatsSuggestionList.swift +++ b/RxCode/Views/Chat/RecentChatsSuggestionList.swift @@ -11,6 +11,7 @@ struct RecentChatsSuggestionList: View { @State private var renamingSession: ChatSession? @State private var renameText = "" @State private var sessionToDelete: ChatSession? + @State private var sessionToArchive: ChatSession? var body: some View { if shouldShow { @@ -43,6 +44,23 @@ struct RecentChatsSuggestionList: View { Text("This session will be deleted. This action cannot be undone.") } } + .alert("Archive Chat", isPresented: isArchivingSessionBinding) { + Button("Archive", role: .destructive) { + if let session = sessionToArchive { + Task { await appState.archiveSession(session, in: windowState) } + } + sessionToArchive = nil + } + Button("Cancel", role: .cancel) { + sessionToArchive = nil + } + } message: { + if let session = sessionToArchive { + Text("\"\(session.title)\" will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } else { + Text("This chat will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } + } .alert("Rename Session", isPresented: isRenamingBinding) { TextField("Session name", text: $renameText) Button("Rename") { @@ -108,12 +126,12 @@ struct RecentChatsSuggestionList: View { } Button { - Task { - if summary.isArchived { + if summary.isArchived { + Task { await appState.unarchiveSession(chatSession, in: windowState) - } else { - await appState.archiveSession(chatSession, in: windowState) } + } else { + sessionToArchive = chatSession } } label: { if summary.isArchived { @@ -169,6 +187,13 @@ struct RecentChatsSuggestionList: View { ) } + private var isArchivingSessionBinding: Binding { + Binding( + get: { sessionToArchive != nil }, + set: { if !$0 { sessionToArchive = nil } } + ) + } + private var isRenamingBinding: Binding { Binding( get: { renamingSession != nil }, diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index c927fe03..ed05f7f5 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -10,6 +10,7 @@ struct HistoryListView: View { @AppStorage("historyShowArchived") private var showArchived = false @State private var showDeleteAllAlert = false @State private var sessionToDelete: ChatSession? + @State private var sessionToArchive: ChatSession? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -65,6 +66,23 @@ struct HistoryListView: View { Text("This session will be deleted. This action cannot be undone.") } } + .alert("Archive Chat", isPresented: isArchivingSessionBinding) { + Button("Archive", role: .destructive) { + if let session = sessionToArchive { + Task { await appState.archiveSession(session, in: windowState) } + } + sessionToArchive = nil + } + Button("Cancel", role: .cancel) { + sessionToArchive = nil + } + } message: { + if let session = sessionToArchive { + Text("\"\(session.title)\" will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } else { + Text("This chat will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } + } .alert("Rename Session", isPresented: isRenamingBinding) { TextField("Session name", text: $renameText) Button("Rename") { @@ -183,12 +201,12 @@ struct HistoryListView: View { } Button { - Task { - if summary.isArchived { + if summary.isArchived { + Task { await appState.unarchiveSession(chatSession, in: windowState) - } else { - await appState.archiveSession(chatSession, in: windowState) } + } else { + sessionToArchive = chatSession } } label: { if summary.isArchived { @@ -327,6 +345,13 @@ struct HistoryListView: View { ) } + private var isArchivingSessionBinding: Binding { + Binding( + get: { sessionToArchive != nil }, + set: { if !$0 { sessionToArchive = nil } } + ) + } + private var isRenamingBinding: Binding { Binding( get: { renamingSession != nil }, diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index d98d770b..d08dcccc 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -20,6 +20,7 @@ struct ProjectTreeView: View { @State private var renameSession: ChatSession? = nil @State private var renameSessionText: String = "" @State private var deleteSession: ChatSession? = nil + @State private var archiveSession: ChatSession? = nil @State private var showAllChatsSheet = false @@ -103,6 +104,24 @@ struct ProjectTreeView: View { Text("This session will be deleted. This action cannot be undone.") } } + .alert("Archive Chat", isPresented: Binding( + get: { archiveSession != nil }, + set: { if !$0 { archiveSession = nil } } + )) { + Button("Archive", role: .destructive) { + if let s = archiveSession { + Task { await appState.archiveSession(s, in: windowState) } + } + archiveSession = nil + } + Button("Cancel", role: .cancel) { archiveSession = nil } + } message: { + if let s = archiveSession { + Text("\"\(s.title)\" will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } else { + Text("This chat will be archived. Archived chats are hidden from the active list and can be restored from Archived.") + } + } .sheet(isPresented: $showAllChatsSheet) { AllChatsHistorySheet(isPresented: $showAllChatsSheet) } @@ -247,6 +266,9 @@ private struct SummarySidebarSection: View { renameSessionText = session.title renameSession = session }, + onArchiveSession: { session in + archiveSession = session + }, onDeleteSession: { session in deleteSession = session } @@ -430,6 +452,7 @@ private struct ProjectChatsList: View { let project: Project let onSelectSession: (String) -> Void let onRenameSession: (ChatSession) -> Void + let onArchiveSession: (ChatSession) -> Void let onDeleteSession: (ChatSession) -> Void @State private var showsAllThreads = false @@ -537,12 +560,12 @@ private struct ProjectChatsList: View { Task { await appState.togglePinSession(session) } }, onToggleArchive: { - Task { - if summary.isArchived { + if summary.isArchived { + Task { await appState.unarchiveSession(session, in: windowState) - } else { - await appState.archiveSession(session, in: windowState) } + } else { + onArchiveSession(session) } }, onDelete: { diff --git a/RxCodeAndroid/.gitignore b/RxCodeAndroid/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/RxCodeAndroid/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/RxCodeAndroid/.idea/.gitignore b/RxCodeAndroid/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/RxCodeAndroid/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/RxCodeAndroid/.idea/AndroidProjectSystem.xml b/RxCodeAndroid/.idea/AndroidProjectSystem.xml new file mode 100644 index 00000000..4a53bee8 --- /dev/null +++ b/RxCodeAndroid/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/compiler.xml b/RxCodeAndroid/.idea/compiler.xml new file mode 100644 index 00000000..b86273d9 --- /dev/null +++ b/RxCodeAndroid/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/deploymentTargetSelector.xml b/RxCodeAndroid/.idea/deploymentTargetSelector.xml new file mode 100644 index 00000000..7ff7aa72 --- /dev/null +++ b/RxCodeAndroid/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/gradle.xml b/RxCodeAndroid/.idea/gradle.xml new file mode 100644 index 00000000..d124cf2a --- /dev/null +++ b/RxCodeAndroid/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/migrations.xml b/RxCodeAndroid/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/RxCodeAndroid/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/misc.xml b/RxCodeAndroid/.idea/misc.xml new file mode 100644 index 00000000..74dd639e --- /dev/null +++ b/RxCodeAndroid/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/runConfigurations.xml b/RxCodeAndroid/.idea/runConfigurations.xml new file mode 100644 index 00000000..16660f1d --- /dev/null +++ b/RxCodeAndroid/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.idea/vcs.xml b/RxCodeAndroid/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/RxCodeAndroid/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/RxCodeAndroid/.kotlin/errors/errors-1779702003413.log b/RxCodeAndroid/.kotlin/errors/errors-1779702003413.log new file mode 100644 index 00000000..f08f6a0b --- /dev/null +++ b/RxCodeAndroid/.kotlin/errors/errors-1779702003413.log @@ -0,0 +1,54 @@ +kotlin version: 2.0.21 +error message: java.nio.file.NoSuchFileException: /Users/qiweili/Desktop/rxlab/RxCode/RxCodeAndroid/app/build/kspCaches/debug/backups/java + at java.base/sun.nio.fs.UnixException.translateToIOException(Unknown Source) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystem.copy(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystemProvider.copy(Unknown Source) + at java.base/java.nio.file.Files.copy(Unknown Source) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:80) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalContextBase.updateOutputs(IncrementalContextBase.kt:355) + at com.google.devtools.ksp.common.IncrementalContextBase.access$updateOutputs(IncrementalContextBase.kt:62) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:484) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.common.IncrementalContextBase.closeFilesOnException(IncrementalContextBase.kt:408) + at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:376) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373) + at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/RxCodeAndroid/.kotlin/errors/errors-1779703098971.log b/RxCodeAndroid/.kotlin/errors/errors-1779703098971.log new file mode 100644 index 00000000..43769bc6 --- /dev/null +++ b/RxCodeAndroid/.kotlin/errors/errors-1779703098971.log @@ -0,0 +1,52 @@ +kotlin version: 2.0.21 +error message: java.nio.file.NoSuchFileException: /Users/qiweili/Desktop/rxlab/RxCode/RxCodeAndroid/app/build/generated/ksp/debug/java/byRounds/1/dagger + at java.base/sun.nio.fs.UnixException.translateToIOException(Unknown Source) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.UnixException.rethrowAsIOException(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystem.copy(Unknown Source) + at java.base/sun.nio.fs.UnixFileSystemProvider.copy(Unknown Source) + at java.base/java.nio.file.Files.copy(Unknown Source) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:80) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalUtilKt.copyWithTimestamp(IncrementalUtil.kt:71) + at com.google.devtools.ksp.common.IncrementalContextBase.updateOutputs(IncrementalContextBase.kt:349) + at com.google.devtools.ksp.common.IncrementalContextBase.access$updateOutputs(IncrementalContextBase.kt:62) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:484) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.common.IncrementalContextBase.closeFilesOnException(IncrementalContextBase.kt:408) + at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:376) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373) + at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/RxCodeAndroid/.kotlin/errors/errors-1779703100049.log b/RxCodeAndroid/.kotlin/errors/errors-1779703100049.log new file mode 100644 index 00000000..4a3dc12b --- /dev/null +++ b/RxCodeAndroid/.kotlin/errors/errors-1779703100049.log @@ -0,0 +1,58 @@ +kotlin version: 2.0.21 +error message: java.io.EOFException + at java.base/java.io.DataInputStream.readUnsignedByte(Unknown Source) + at org.jetbrains.kotlin.com.intellij.openapi.util.io.DataInputOutputUtilRt.readINT(DataInputOutputUtilRt.java:16) + at org.jetbrains.kotlin.com.intellij.util.io.DataInputOutputUtil.readINT(DataInputOutputUtil.java:23) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMapValueStorage.readChunkSize(PersistentHashMapValueStorage.java:690) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMapValueStorage.readBytes(PersistentHashMapValueStorage.java:591) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doGet(PersistentMapImpl.java:676) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.get(PersistentMapImpl.java:613) + at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.get(PersistentHashMap.java:196) + at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76) + at org.jetbrains.kotlin.incremental.storage.PersistentStorageWrapper.get(PersistentStorage.kt:92) + at org.jetbrains.kotlin.incremental.LookupStorage.addFileIfNeeded(LookupStorage.kt:160) + at org.jetbrains.kotlin.incremental.LookupStorage.addAll$lambda$4(LookupStorage.kt:117) + at org.jetbrains.kotlin.utils.CollectionsKt.keysToMap(collections.kt:117) + at org.jetbrains.kotlin.incremental.LookupStorage.addAll(LookupStorage.kt:117) + at org.jetbrains.kotlin.incremental.BuildUtilKt.update(buildUtil.kt:134) + at com.google.devtools.ksp.LookupStorageWrapperImpl.update(IncrementalContext.kt:231) + at com.google.devtools.ksp.common.IncrementalContextBase.updateLookupCache(IncrementalContextBase.kt:133) + at com.google.devtools.ksp.common.IncrementalContextBase.updateCaches(IncrementalContextBase.kt:365) + at com.google.devtools.ksp.common.IncrementalContextBase.access$updateCaches(IncrementalContextBase.kt:62) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:476) + at com.google.devtools.ksp.common.IncrementalContextBase$updateCachesAndOutputs$1.invoke(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.common.IncrementalContextBase.closeFilesOnException(IncrementalContextBase.kt:408) + at com.google.devtools.ksp.common.IncrementalContextBase.updateCachesAndOutputs(IncrementalContextBase.kt:428) + at com.google.devtools.ksp.AbstractKotlinSymbolProcessingExtension.doAnalysis(KotlinSymbolProcessingExtension.kt:376) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration(TopDownAnalyzerFacadeForJVM.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration$default(TopDownAnalyzerFacadeForJVM.kt:75) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze$lambda$12(KotlinToJVMBytecodeCompiler.kt:373) + at org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport.analyzeAndReport(AnalyzerWithCompilerReport.kt:112) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.analyze(KotlinToJVMBytecodeCompiler.kt:364) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.repeatAnalysisIfNeeded(KotlinToJVMBytecodeCompiler.kt:282) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.runFrontendAndGenerateIrUsingClassicFrontend(KotlinToJVMBytecodeCompiler.kt:195) + at org.jetbrains.kotlin.cli.jvm.compiler.KotlinToJVMBytecodeCompiler.compileModules$cli(KotlinToJVMBytecodeCompiler.kt:106) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:170) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1555) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/RxCodeAndroid/app/.gitignore b/RxCodeAndroid/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/RxCodeAndroid/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/RxCodeAndroid/app/build.gradle.kts b/RxCodeAndroid/app/build.gradle.kts new file mode 100644 index 00000000..5ea035e8 --- /dev/null +++ b/RxCodeAndroid/app/build.gradle.kts @@ -0,0 +1,98 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "app.rxlab.rxcode" + compileSdk = 36 + + defaultConfig { + applicationId = "app.rxlab.rxcode" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material3.adaptive.navigation.suite) + implementation(libs.compose.material3.adaptive) + implementation(libs.compose.material3.adaptive.layout) + implementation(libs.compose.material3.adaptive.navigation) + implementation(libs.compose.material.icons.extended) + debugImplementation(libs.compose.ui.tooling) + + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.webkit) + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) + + implementation(libs.okhttp) + + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.security.crypto) + implementation(libs.bouncycastle) + + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.mlkit.barcode.scanning) + implementation(libs.accompanist.permissions) + + implementation(libs.coil.compose) + implementation(libs.compose.markdown) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/RxCodeAndroid/app/proguard-rules.pro b/RxCodeAndroid/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/RxCodeAndroid/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/RxCodeAndroid/app/src/debug/AndroidManifest.xml b/RxCodeAndroid/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..ba2bc889 --- /dev/null +++ b/RxCodeAndroid/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/RxCodeAndroid/app/src/debug/res/xml/debug_network_security_config.xml b/RxCodeAndroid/app/src/debug/res/xml/debug_network_security_config.xml new file mode 100644 index 00000000..2439f15c --- /dev/null +++ b/RxCodeAndroid/app/src/debug/res/xml/debug_network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/RxCodeAndroid/app/src/main/AndroidManifest.xml b/RxCodeAndroid/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..27093f5a --- /dev/null +++ b/RxCodeAndroid/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt new file mode 100644 index 00000000..0ced253f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/MainActivity.kt @@ -0,0 +1,81 @@ +package app.rxlab.rxcode + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import app.rxlab.rxcode.pairing.PairingToken +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.ui.RxCodeApp +import app.rxlab.rxcode.ui.theme.RxCodeTheme +import app.rxlab.rxcode.ui.util.hideKeyboardOnUserScroll +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private var pendingDeeplink by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.w(TAG, "MainActivity.onCreate data=${intent?.dataString?.let { "present" } ?: "none"}") + enableEdgeToEdge() + pendingDeeplink = intent?.dataString + setContent { + RxCodeTheme { + val vm: MobileAppState = hiltViewModel() + val state by vm.state.collectAsState() + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(vm) { vm.start() } + DisposableEffect(vm, lifecycle) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> vm.handleAppForeground() + Lifecycle.Event.ON_STOP -> vm.handleAppBackground() + else -> Unit + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + LaunchedEffect(pendingDeeplink) { + pendingDeeplink?.let { url -> + PairingToken.parseOrNull(url)?.let(vm::beginPairing) + pendingDeeplink = null + } + } + Box( + modifier = Modifier + .fillMaxSize() + .hideKeyboardOnUserScroll(), + ) { + RxCodeApp(state = state, viewModel = vm) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Log.w(TAG, "MainActivity.onNewIntent data=${intent.dataString?.let { "present" } ?: "none"}") + intent.dataString?.let { pendingDeeplink = it } + } + + private companion object { + private const val TAG = "RxCodeStartup" + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt new file mode 100644 index 00000000..32820d81 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/RxCodeApplication.kt @@ -0,0 +1,17 @@ +package app.rxlab.rxcode + +import android.app.Application +import android.util.Log +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class RxCodeApplication : Application() { + override fun onCreate() { + super.onCreate() + Log.w(TAG, "RxCodeApplication launched") + } + + private companion object { + private const val TAG = "RxCodeStartup" + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/Hex.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/Hex.kt new file mode 100644 index 00000000..21ac975f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/Hex.kt @@ -0,0 +1,28 @@ +package app.rxlab.rxcode.crypto + +object Hex { + private val HEX = "0123456789abcdef".toCharArray() + + fun encode(bytes: ByteArray): String { + val out = CharArray(bytes.size * 2) + for (i in bytes.indices) { + val v = bytes[i].toInt() and 0xFF + out[i * 2] = HEX[v ushr 4] + out[i * 2 + 1] = HEX[v and 0x0F] + } + return String(out) + } + + fun decode(hex: String): ByteArray? { + val trimmed = hex.trim() + if (trimmed.length % 2 != 0) return null + val out = ByteArray(trimmed.length / 2) + for (i in out.indices) { + val hi = Character.digit(trimmed[i * 2], 16) + val lo = Character.digit(trimmed[i * 2 + 1], 16) + if (hi < 0 || lo < 0) return null + out[i] = ((hi shl 4) or lo).toByte() + } + return out + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SessionCrypto.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SessionCrypto.kt new file mode 100644 index 00000000..91182e4b --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/crypto/SessionCrypto.kt @@ -0,0 +1,178 @@ +package app.rxlab.rxcode.crypto + +import org.bouncycastle.crypto.agreement.X25519Agreement +import org.bouncycastle.crypto.engines.ChaCha7539Engine +import org.bouncycastle.crypto.macs.Poly1305 +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.ParametersWithIV +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import java.security.SecureRandom +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * Port of `Packages/Sources/RxCodeSync/Crypto/SessionCrypto.swift`. + * + * X25519 ECDH + HKDF-SHA256 derives a 32-byte symmetric key, then ChaCha20-Poly1305 + * (IETF, 12-byte nonce) seals plaintext as `ciphertext || tag(16)`. The HKDF salt + * is the byte-sorted concatenation of both pubkeys, optionally with extra salt + * appended — both peers derive the same key regardless of who originated the call. + */ +object SessionCrypto { + private const val KDF_INFO = "rxcode-sync/v1" + private val rng = SecureRandom() + + data class Sealed(val nonce: ByteArray, val ciphertext: ByteArray) + + fun seal( + plaintext: ByteArray, + senderPrivate: X25519PrivateKeyParameters, + recipientPublic: X25519PublicKeyParameters, + extraSalt: ByteArray? = null + ): Sealed { + val senderPublic = senderPrivate.generatePublicKey() + val key = deriveKey(senderPrivate, recipientPublic, senderPublic.encoded, recipientPublic.encoded, extraSalt) + val nonce = ByteArray(12).also { rng.nextBytes(it) } + val (ct, tag) = chachaPolySeal(key, nonce, plaintext) + return Sealed(nonce, ct + tag) + } + + fun open( + ciphertextWithTag: ByteArray, + nonce: ByteArray, + recipientPrivate: X25519PrivateKeyParameters, + senderPublic: X25519PublicKeyParameters, + extraSalt: ByteArray? = null + ): ByteArray { + require(ciphertextWithTag.size >= 16) { "ciphertext too short" } + require(nonce.size == 12) { "nonce must be 12 bytes" } + val recipientPublic = recipientPrivate.generatePublicKey() + val key = deriveKey(recipientPrivate, senderPublic, recipientPublic.encoded, senderPublic.encoded, extraSalt) + val tag = ciphertextWithTag.copyOfRange(ciphertextWithTag.size - 16, ciphertextWithTag.size) + val ct = ciphertextWithTag.copyOfRange(0, ciphertextWithTag.size - 16) + return chachaPolyOpen(key, nonce, ct, tag) + } + + private fun deriveKey( + ourPrivate: X25519PrivateKeyParameters, + theirPublic: X25519PublicKeyParameters, + ourPubBytes: ByteArray, + theirPubBytes: ByteArray, + extraSalt: ByteArray? + ): ByteArray { + val agreement = X25519Agreement().apply { init(ourPrivate) } + val shared = ByteArray(agreement.agreementSize) + agreement.calculateAgreement(theirPublic, shared, 0) + val sortedSalt = sortedConcat(ourPubBytes, theirPubBytes) + val salt = if (extraSalt != null) sortedSalt + extraSalt else sortedSalt + return hkdfSha256(shared, salt, KDF_INFO.toByteArray(Charsets.UTF_8), 32) + } + + private fun sortedConcat(a: ByteArray, b: ByteArray): ByteArray = + if (lexicographicallyPrecedes(a, b)) a + b else b + a + + private fun lexicographicallyPrecedes(a: ByteArray, b: ByteArray): Boolean { + val n = minOf(a.size, b.size) + for (i in 0 until n) { + val ai = a[i].toInt() and 0xFF + val bi = b[i].toInt() and 0xFF + if (ai != bi) return ai < bi + } + return a.size < b.size + } + + // RFC 5869 HKDF-SHA256 + private fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + val prk = hmacSha256(salt, ikm) + var t = ByteArray(0) + val out = ByteArray(length) + var generated = 0 + var counter = 1 + while (generated < length) { + val input = t + info + byteArrayOf(counter.toByte()) + t = hmacSha256(prk, input) + val take = minOf(t.size, length - generated) + System.arraycopy(t, 0, out, generated, take) + generated += take + counter += 1 + } + return out + } + + private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } + + // ChaCha20-Poly1305 (IETF / RFC 8439) implemented over BouncyCastle's + // primitives so we don't depend on a single provider's "ChaCha20-Poly1305" + // Cipher availability across API levels. + private fun chachaPolySeal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray): Pair { + val polyKey = chachaBlockZero(key, nonce) + val ct = chacha20Encrypt(key, nonce, plaintext) + val tag = poly1305Tag(polyKey, ByteArray(0), ct) + return ct to tag + } + + private fun chachaPolyOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray { + val polyKey = chachaBlockZero(key, nonce) + val expected = poly1305Tag(polyKey, ByteArray(0), ciphertext) + if (!constantTimeEquals(expected, tag)) throw SecurityException("Poly1305 tag mismatch") + return chacha20Encrypt(key, nonce, ciphertext) + } + + private fun chachaBlockZero(key: ByteArray, nonce: ByteArray): ByteArray { + val engine = ChaCha7539Engine() + engine.init(true, ParametersWithIV(KeyParameter(key), nonce)) + val out = ByteArray(64) + engine.processBytes(ByteArray(64), 0, 64, out, 0) + return out.copyOfRange(0, 32) + } + + private fun chacha20Encrypt(key: ByteArray, nonce: ByteArray, input: ByteArray): ByteArray { + val engine = ChaCha7539Engine() + engine.init(true, ParametersWithIV(KeyParameter(key), nonce)) + // Consume the first 64-byte block so encryption starts at counter=1 + // (RFC 8439: block 0 is reserved for the Poly1305 one-time key). + val sink = ByteArray(64) + engine.processBytes(ByteArray(64), 0, 64, sink, 0) + val out = ByteArray(input.size) + engine.processBytes(input, 0, input.size, out, 0) + return out + } + + private fun poly1305Tag(key: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray { + val mac = Poly1305() + mac.init(KeyParameter(key)) + val aadPadded = padTo16(aad) + val ctPadded = padTo16(ciphertext) + mac.update(aadPadded, 0, aadPadded.size) + mac.update(ctPadded, 0, ctPadded.size) + val lengths = ByteArray(16) + writeLeUInt64(lengths, 0, aad.size.toLong()) + writeLeUInt64(lengths, 8, ciphertext.size.toLong()) + mac.update(lengths, 0, 16) + val tag = ByteArray(16) + mac.doFinal(tag, 0) + return tag + } + + private fun padTo16(input: ByteArray): ByteArray { + val rem = input.size % 16 + if (rem == 0) return input + return input + ByteArray(16 - rem) + } + + private fun writeLeUInt64(dst: ByteArray, offset: Int, value: Long) { + for (i in 0 until 8) dst[offset + i] = ((value ushr (8 * i)) and 0xFF).toByte() + } + + private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/di/AppModule.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/di/AppModule.kt new file mode 100644 index 00000000..30249d06 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/di/AppModule.kt @@ -0,0 +1,29 @@ +package app.rxlab.rxcode.di + +import android.content.Context +import app.rxlab.rxcode.identity.DeviceIdentity +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.store.PairingStore +import app.rxlab.rxcode.sync.SyncClient +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides @Singleton + fun provideDeviceIdentity(@ApplicationContext ctx: Context): DeviceIdentity = + DeviceIdentity.loadOrCreate(ctx) + + @Provides @Singleton + fun providePairingStore(@ApplicationContext ctx: Context): PairingStore = + PairingStore(ctx) + + @Provides @Singleton + fun provideSyncClient(identity: DeviceIdentity): SyncClient = + SyncClient(identity = identity, relayUrl = MobileAppState.defaultRelayUrl()) +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/identity/DeviceIdentity.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/identity/DeviceIdentity.kt new file mode 100644 index 00000000..12f60c71 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/identity/DeviceIdentity.kt @@ -0,0 +1,58 @@ +package app.rxlab.rxcode.identity + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import app.rxlab.rxcode.crypto.Hex +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import java.security.SecureRandom + +/** + * Mobile device identity: a stable X25519 keypair persisted in Jetpack Security + * `EncryptedSharedPreferences` (AES-256-GCM via Android Keystore). + * + * Mirrors `Packages/Sources/RxCodeSync/Crypto/DeviceIdentity.swift` — the + * public key (hex) is what's sent to the desktop during pairing. + */ +class DeviceIdentity private constructor( + val privateKey: X25519PrivateKeyParameters, + val publicKey: X25519PublicKeyParameters, +) { + val publicKeyHex: String = Hex.encode(publicKey.encoded) + + companion object { + private const val PREFS = "rxcode.device-identity" + private const val KEY_PRIVATE = "x25519-private-key" + + fun loadOrCreate(context: Context): DeviceIdentity { + val masterKey = MasterKey.Builder(context.applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val prefs = EncryptedSharedPreferences.create( + context.applicationContext, + PREFS, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val existing = prefs.getString(KEY_PRIVATE, null) + val privBytes: ByteArray = if (existing != null) { + Hex.decode(existing) ?: generate().also { prefs.edit().putString(KEY_PRIVATE, Hex.encode(it)).apply() } + } else { + val fresh = generate() + prefs.edit().putString(KEY_PRIVATE, Hex.encode(fresh)).apply() + fresh + } + val priv = X25519PrivateKeyParameters(privBytes, 0) + return DeviceIdentity(priv, priv.generatePublicKey()) + } + + private fun generate(): ByteArray { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/pairing/PairingToken.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/pairing/PairingToken.kt new file mode 100644 index 00000000..fceea0d1 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/pairing/PairingToken.kt @@ -0,0 +1,59 @@ +package app.rxlab.rxcode.pairing + +import android.net.Uri +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.proto.SwiftDateSerializer +import app.rxlab.rxcode.proto.UuidSerializer +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.Base64 +import java.util.UUID + +/** + * QR-code payload the desktop generates and the mobile scans. Wire-compatible + * with Swift's `PairingToken` (base64-JSON, ISO-style Cocoa epoch dates). + */ +@Serializable +data class PairingToken( + val v: Int = 1, + val relayURL: String, + val desktopPubkeyHex: String, + @Serializable(with = UuidSerializer::class) val sessionID: UUID, + val oneTimeSecretHex: String, + @Serializable(with = SwiftDateSerializer::class) val issuedAt: Instant, + @Serializable(with = SwiftDateSerializer::class) val expiresAt: Instant, + val desktopName: String, +) { + val isExpired: Boolean get() = Instant.now().isAfter(expiresAt) + + companion object { + const val DEEPLINK_HOST = "code.rxlab.app" + const val DEEPLINK_PATH = "/pair" + const val LEGACY_SCHEME_PREFIX = "rxcode-pair:" + + /** + * Parse either the modern `https://code.rxlab.app/pair?token=...` or the + * legacy `rxcode-pair:` form. Returns null on a malformed input. + */ + fun parseOrNull(input: String): PairingToken? { + val trimmed = input.trim() + val b64 = when { + trimmed.startsWith(LEGACY_SCHEME_PREFIX) -> + trimmed.removePrefix(LEGACY_SCHEME_PREFIX) + else -> tokenFromDeeplink(trimmed) ?: return null + } + return runCatching { + val raw = Base64.getDecoder().decode(b64) + RxJson.decodeFromString(serializer(), String(raw, Charsets.UTF_8)) + }.getOrNull() + } + + private fun tokenFromDeeplink(input: String): String? { + val uri = runCatching { Uri.parse(input) }.getOrNull() ?: return null + if (!uri.scheme.equals("https", true)) return null + if (!uri.host.equals(DEEPLINK_HOST, true)) return null + if (uri.path != DEEPLINK_PATH) return null + return uri.getQueryParameter("token") + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Envelope.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Envelope.kt new file mode 100644 index 00000000..a945072d --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Envelope.kt @@ -0,0 +1,76 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Base64 + +/** Cleartext relay envelope. `ct` is opaque to the relay. */ +@Serializable +data class Envelope( + val v: Int = 1, + val to: String, + val from: String, + val nonce: String, + val ct: String, +) { + val nonceData: ByteArray? get() = decodeB64(nonce) + val ciphertextData: ByteArray? get() = decodeB64(ct) + + companion object { + fun build(toHex: String, fromHex: String, nonce: ByteArray, ciphertext: ByteArray): Envelope = + Envelope( + v = 1, + to = toHex, + from = fromHex, + nonce = Base64.getEncoder().encodeToString(nonce), + ct = Base64.getEncoder().encodeToString(ciphertext), + ) + + private fun decodeB64(s: String): ByteArray? = try { + Base64.getDecoder().decode(s) + } catch (_: IllegalArgumentException) { + null + } + } +} + +/** Sent by the relay back when the recipient is offline. */ +@Serializable +data class DeliveryFailedNotice( + val v: Int, + val type: String, + val to: String, +) { + companion object { + const val TYPE = "delivery_failed" + } +} + +@Serializable +private data class TypeOnly(@SerialName("type") val type: String? = null) + +internal fun envelopeOrDeliveryFailure(raw: String): EnvelopeOrFailure? { + val element = try { + RxJson.parseToJsonElement(raw) + } catch (_: Throwable) { + return null + } + val type = (element as? kotlinx.serialization.json.JsonObject) + ?.get("type")?.toString()?.trim('"') + return when (type) { + DeliveryFailedNotice.TYPE -> EnvelopeOrFailure.Failure(RxJson.decodeFromJsonElement(DeliveryFailedNotice.serializer(), element)) + else -> { + val env = try { + RxJson.decodeFromJsonElement(Envelope.serializer(), element) + } catch (_: Throwable) { + return null + } + EnvelopeOrFailure.Env(env) + } + } +} + +internal sealed interface EnvelopeOrFailure { + data class Env(val envelope: Envelope) : EnvelopeOrFailure + data class Failure(val notice: DeliveryFailedNotice) : EnvelopeOrFailure +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt new file mode 100644 index 00000000..6f38a1f1 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Json.kt @@ -0,0 +1,78 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import java.time.Instant +import java.util.UUID + +/** Shared JSON config: Swift's default JSONEncoder/JSONDecoder behavior. */ +val RxJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + explicitNulls = false + isLenient = true + coerceInputValues = true +} + +/** + * Mirrors Foundation's default `Date` encoding: seconds (Double) since the + * Cocoa reference date (2001-01-01 00:00:00 UTC). + * + * Reference date epoch (UTC seconds since 1970-01-01) = 978307200. + */ +object SwiftDateSerializer : KSerializer { + private const val REFERENCE_EPOCH_SECONDS = 978_307_200L + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("SwiftDate", PrimitiveKind.DOUBLE) + + override fun serialize(encoder: Encoder, value: Instant) { + val secondsSinceReference = value.epochSecond - REFERENCE_EPOCH_SECONDS + + value.nano / 1_000_000_000.0 + encoder.encodeDouble(secondsSinceReference) + } + + override fun deserialize(decoder: Decoder): Instant { + if (decoder is JsonDecoder) { + val primitive = decoder.decodeJsonElement() as? JsonPrimitive + ?: throw SerializationException("Expected date primitive") + primitive.doubleOrNull?.let { return decodeCocoaReferenceDate(it) } + primitive.contentOrNull?.let { content -> + content.toDoubleOrNull()?.let { return decodeCocoaReferenceDate(it) } + return Instant.parse(content) + } + throw SerializationException("Expected date string or number") + } + return decodeCocoaReferenceDate(decoder.decodeDouble()) + } + + private fun decodeCocoaReferenceDate(secondsSinceReference: Double): Instant { + val epochSeconds = REFERENCE_EPOCH_SECONDS + secondsSinceReference.toLong() + val nanoFraction = ((secondsSinceReference - secondsSinceReference.toLong()) * 1_000_000_000) + .toLong().coerceAtLeast(0L) + return Instant.ofEpochSecond(epochSeconds, nanoFraction) + } +} + +/** Swift `UUID` encodes as an uppercase hyphenated string. */ +object UuidSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UUID) { + encoder.encodeString(value.toString().uppercase()) + } + + override fun deserialize(decoder: Decoder): UUID = + UUID.fromString(decoder.decodeString()) +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt new file mode 100644 index 00000000..f2071912 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Models.kt @@ -0,0 +1,147 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +/** + * Domain types mirroring the Swift `RxCodeCore` and `RxCodeSync` models that + * Phase 1 of the Android client needs. Trimmed to fields the UI actually reads; + * unknown JSON keys are tolerated by [RxJson] so newer desktops can extend the + * wire format without breaking us. + */ + +@Serializable +data class Project( + @Serializable(with = UuidSerializer::class) val id: UUID, + val name: String, + val path: String, + val gitHubRepo: String? = null, + val lastSessionId: String? = null, +) + +@Serializable +data class SessionSummary( + val id: String, + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val title: String, + @Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant, + val isPinned: Boolean = false, + val isArchived: Boolean = false, + val isStreaming: Boolean = false, + val attention: SessionAttentionKind? = null, + val hasUncheckedCompletion: Boolean = false, + val queuedMessages: List = emptyList(), + val todos: List? = null, +) + +@Serializable +enum class SessionAttentionKind { + @SerialName("permission") PERMISSION, + @SerialName("question") QUESTION, +} + +@Serializable +data class QueuedUserMessage( + @Serializable(with = UuidSerializer::class) val id: UUID, + val text: String, +) + +@Serializable +enum class Role { + @SerialName("user") USER, + @SerialName("assistant") ASSISTANT, +} + +@Serializable +data class ToolCall( + val id: String, + val name: String, + val input: kotlinx.serialization.json.JsonObject = + kotlinx.serialization.json.JsonObject(emptyMap()), + val result: String? = null, + val isError: Boolean = false, +) + +@Serializable +data class MessageBlock( + val id: String, + val text: String? = null, + val toolCall: ToolCall? = null, +) + +@Serializable +data class ChatMessage( + @Serializable(with = UuidSerializer::class) val id: UUID, + val role: Role, + val blocks: List = emptyList(), + val isStreaming: Boolean = false, + val isResponseComplete: Boolean = false, + @Serializable(with = SwiftDateSerializer::class) val timestamp: Instant, + val duration: Double? = null, + val isError: Boolean = false, + val isCompactBoundary: Boolean = false, +) { + val textContent: String + get() = blocks.mapNotNull { it.text }.joinToString("\n\n") + + val toolCalls: List get() = blocks.mapNotNull { it.toolCall } +} + +@Serializable +enum class AgentProvider { + @SerialName("claudeCode") CLAUDE_CODE, + @SerialName("codex") CODEX, + @SerialName("acp") ACP, +} + +@Serializable +enum class PermissionMode { + @SerialName("default") DEFAULT, + @SerialName("acceptEdits") ACCEPT_EDITS, + @SerialName("bypassPermissions") BYPASS_PERMISSIONS, + @SerialName("plan") PLAN, +} + +/** + * Per-branch briefing produced by the desktop after threads complete. + * Mirrors Swift `MobileBranchBriefing`. + */ +@Serializable +data class MobileBranchBriefing( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val branch: String, + val briefing: String, + @Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant, +) { + val id: String get() = "$projectId::$branch" +} + +/** + * Per-thread summary shown inside a briefing card. Mirrors + * Swift `MobileThreadSummary`. + */ +@Serializable +data class MobileThreadSummary( + val sessionId: String, + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val branch: String, + val title: String, + val summary: String, + @Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant, +) { + val id: String get() = sessionId +} + +/** + * Current git branch and available branches for a project, sent by the + * desktop so mobile can populate the branch picker in the new-thread sheet + * and the briefing filter. + */ +@Serializable +data class ProjectBranchInfo( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val currentBranch: String, + val availableBranches: List? = null, +) 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 new file mode 100644 index 00000000..5dbd87c3 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/Payload.kt @@ -0,0 +1,540 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant +import java.util.UUID + +/** + * Plaintext payloads exchanged between paired devices. Wire shape is + * `{ "type": "", "data": {...} }`; unknown types decode to + * [Payload.Unknown] so a newer desktop can talk to an older client without + * dropping the whole envelope. + * + * Only the subset of payloads Phase 1 actually consumes carries a strongly + * typed body; everything else falls through to [Payload.Unknown] until later + * phases extend it. + */ +@Serializable(with = PayloadSerializer::class) +sealed class Payload { + abstract val type: String + + data class PairRequest(val data: PairRequestPayload) : Payload() { + override val type = "pair_request" + } + data class PairAck(val data: PairAckPayload) : Payload() { + override val type = "pair_ack" + } + data class Unpair(val data: UnpairPayload) : Payload() { + override val type = "unpair" + } + data class RequestSnapshot(val data: RequestSnapshotPayload) : Payload() { + override val type = "request_snapshot" + } + data class Snapshot(val data: SnapshotPayload) : Payload() { + override val type = "snapshot" + } + data class SessionUpdate(val data: SessionUpdatePayload) : Payload() { + override val type = "session_update" + } + data class SubscribeSession(val data: SubscribeSessionPayload) : Payload() { + override val type = "subscribe_session" + } + data class UserMessage(val data: UserMessagePayload) : Payload() { + override val type = "user_message" + } + data class CancelStream(val data: CancelStreamPayload) : Payload() { + override val type = "cancel_stream" + } + data class NewSessionRequest(val data: NewSessionRequestPayload) : Payload() { + override val type = "new_session_request" + } + data class ThreadActionRequest(val data: ThreadActionRequestPayload) : Payload() { + override val type = "thread_action_request" + } + data class LoadMoreMessages(val data: LoadMoreMessagesRequestPayload) : Payload() { + override val type = "load_more_messages" + } + data class MoreMessages(val data: MoreMessagesPayload) : Payload() { + override val type = "more_messages" + } + data class PermissionRequest(val data: PermissionRequestPayload) : Payload() { + override val type = "permission_request" + } + data class PermissionResponse(val data: PermissionResponsePayload) : Payload() { + override val type = "permission_response" + } + data class QuestionQueue(val data: QuestionQueuePayload) : Payload() { + override val type = "question_queue" + } + data class QuestionAnswer(val data: QuestionAnswerPayload) : Payload() { + override val type = "question_answer" + } + data class PlanDecision(val data: PlanDecisionPayload) : Payload() { + override val type = "plan_decision" + } + data class BranchOpRequest(val data: BranchOpRequestPayload) : Payload() { + override val type = "branch_op_request" + } + data class BranchOpResult(val data: BranchOpResultPayload) : Payload() { + override val type = "branch_op_result" + } + data class RunProfileMutationRequest(val data: RunProfileMutationRequestPayload) : Payload() { + override val type = "run_profile_mutation_request" + } + data class RunProfileResult(val data: RunProfileResultPayload) : Payload() { + override val type = "run_profile_result" + } + data class RunProfileRunRequest(val data: RunProfileRunRequestPayload) : Payload() { + override val type = "run_profile_run_request" + } + data class RunProfileStopRequest(val data: RunProfileStopRequestPayload) : Payload() { + override val type = "run_profile_stop_request" + } + data class RunTaskUpdate(val data: RunTaskUpdatePayload) : Payload() { + override val type = "run_task_update" + } + data class ThreadChangesRequest(val data: ThreadChangesRequestPayload) : Payload() { + override val type = "thread_changes_request" + } + data class ThreadChangesResult(val data: ThreadChangesResultPayload) : Payload() { + override val type = "thread_changes_result" + } + data class Ping(val data: PingPayload) : Payload() { + override val type = "ping" + } + data class Pong(val data: PongPayload) : Payload() { + override val type = "pong" + } + data class Unknown(override val type: String, val raw: JsonObject? = null) : Payload() +} + +// MARK: - Wire structs + +@Serializable +data class PairRequestPayload( + val mobilePubkeyHex: String, + val displayName: String, + val platform: String, + val appVersion: String, + val apnsEnvironment: String? = null, +) + +@Serializable +data class PairAckPayload( + val accepted: Boolean, + val desktopName: String, + val reason: String? = null, +) + +@Serializable +data class UnpairPayload(val reason: String? = null) + +@Serializable +data class RequestSnapshotPayload(val activeSessionID: String? = null) + +@Serializable +data class SnapshotPayload( + val projects: List = emptyList(), + val sessions: List = emptyList(), + val branchBriefings: List? = null, + val threadSummaries: List? = null, + val projectBranches: List? = null, + val activeSessionID: String? = null, + val activeSessionMessages: List? = null, + val activeSessionHasMore: Boolean? = null, + val runProfiles: List? = null, + val runTasks: List? = null, + val webProxy: MobileWebProxyInfo? = null, +) + +@Serializable +data class SubscribeSessionPayload(val sessionID: String?) + +@Serializable +data class UserMessagePayload( + @Serializable(with = UuidSerializer::class) val clientMessageID: UUID = UUID.randomUUID(), + val sessionID: String, + val text: String, +) + +@Serializable +data class CancelStreamPayload(val sessionID: String) + +@Serializable +data class NewSessionRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val initialText: String? = null, + val selectedAgentProvider: AgentProvider? = null, + val selectedModel: String? = null, + val permissionMode: PermissionMode? = null, + val planMode: Boolean? = null, +) + +@Serializable +data class ThreadActionRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val sessionID: String, + val action: ThreadAction, + val newTitle: String? = null, +) { + @Serializable + enum class ThreadAction { + @SerialName("rename") RENAME, + @SerialName("archive") ARCHIVE, + @SerialName("unarchive") UNARCHIVE, + @SerialName("delete") DELETE, + } +} + +@Serializable +data class LoadMoreMessagesRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val sessionID: String, + @Serializable(with = UuidSerializer::class) val beforeMessageID: UUID, + val limit: Int, +) + +@Serializable +data class MoreMessagesPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + val sessionID: String, + val messages: List, + val hasMore: Boolean, +) + +@Serializable +data class SessionUpdatePayload( + val sessionID: String, + val kind: Kind, + val message: ChatMessage? = null, + val isStreaming: Boolean? = null, + val isThinking: Boolean? = null, + val summary: SessionSummary? = null, + val previousSessionID: String? = null, +) { + @Serializable + enum class Kind { + @SerialName("messageAppended") MESSAGE_APPENDED, + @SerialName("messageUpdated") MESSAGE_UPDATED, + @SerialName("streamingStarted") STREAMING_STARTED, + @SerialName("streamingFinished") STREAMING_FINISHED, + @SerialName("statusChanged") STATUS_CHANGED, + } +} + +@Serializable +data class PermissionRequestPayload( + val requestID: String, + val toolName: String, + val toolInputJSON: String, + val sessionID: String? = null, +) + +@Serializable +data class PermissionResponsePayload( + val requestID: String, + val allow: Boolean, + val denyReason: String? = null, +) + +@Serializable +data class PendingQuestionPayload( + val toolUseID: String, + val sessionID: String, + val toolInputJSON: String, +) + +@Serializable +data class QuestionQueuePayload(val questions: List) + +@Serializable +data class QuestionAnswerEntry( + val questionIndex: Int, + val values: List, + val multiSelect: Boolean, +) + +@Serializable +data class QuestionAnswerPayload( + val toolUseID: String, + val answers: List, +) + +@Serializable +data class PlanDecisionPayload( + val toolUseID: String, + val sessionID: String, + val action: Action, + val reason: String? = null, +) { + @Serializable + enum class Action { + @SerialName("acceptAsk") ACCEPT_ASK, + @SerialName("acceptWithEdits") ACCEPT_WITH_EDITS, + @SerialName("acceptAutoApprove") ACCEPT_AUTO_APPROVE, + @SerialName("reject") REJECT, + @SerialName("rejectWithFeedback") REJECT_WITH_FEEDBACK, + } +} + +/** + * Mobile-initiated request to switch to / create a branch (with worktree) or + * initialize a git repo in the project root. Desktop replies with + * [BranchOpResultPayload]; on success it broadcasts a fresh snapshot so we + * pick up the new branch metadata. + */ +@Serializable +data class BranchOpRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val operation: Operation, + val branch: String, +) { + @Serializable + enum class Operation { + @SerialName("switchExisting") SWITCH_EXISTING, + @SerialName("createNew") CREATE_NEW, + @SerialName("initGit") INIT_GIT, + } +} + +@Serializable +data class BranchOpResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val operation: BranchOpRequestPayload.Operation, + val branch: String, + val ok: Boolean, + val errorMessage: String? = null, +) + +/** + * Mobile → desktop request to upsert or delete a [RunProfile]. The desktop + * replies with [RunProfileResultPayload]; on success it also broadcasts a + * fresh snapshot so all paired devices converge on the new profile list. + */ +@Serializable +data class RunProfileMutationRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val operation: Operation, + val profile: RunProfile? = null, + @Serializable(with = UuidSerializer::class) val profileID: UUID? = null, +) { + @Serializable + enum class Operation { + @SerialName("upsert") UPSERT, + @SerialName("delete") DELETE, + } +} + +@Serializable +data class RunProfileRunRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectID: UUID, + @Serializable(with = UuidSerializer::class) val profileID: UUID, +) + +@Serializable +data class RunProfileStopRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val taskID: UUID? = null, + @Serializable(with = UuidSerializer::class) val projectID: UUID? = null, + @Serializable(with = UuidSerializer::class) val profileID: UUID? = null, +) + +@Serializable +data class RunProfileResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + @Serializable(with = UuidSerializer::class) val projectID: UUID, + val ok: Boolean, + val errorMessage: String? = null, + val profiles: List? = null, + val task: MobileRunTaskSnapshot? = null, +) + +@Serializable +data class RunTaskUpdatePayload(val task: MobileRunTaskSnapshot) + +/** + * Mobile → desktop request to fetch the list of file changes for a thread. + * The desktop replies with [ThreadChangesResultPayload]. Used by the + * "View Changes" sheet (mirrors iOS `ThreadChangesSheet`). + */ +@Serializable +data class ThreadChangesRequestPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID = UUID.randomUUID(), + val sessionID: String, +) + +@Serializable +data class SyncEditHunk( + val oldString: String, + val newString: String, +) + +@Serializable +data class SyncFileEdit( + val path: String, + val name: String, + val containsWrite: Boolean, + val hunks: List = emptyList(), + val fullFileDiff: String? = null, + val originalContent: String? = null, + val modifiedContent: String? = null, +) + +@Serializable +enum class SyncGitChangeKind { + @SerialName("staged") STAGED, + @SerialName("unstaged") UNSTAGED, + @SerialName("untracked") UNTRACKED, +} + +@Serializable +data class SyncGitChange( + val displayPath: String, + val statusChar: String, + val kind: SyncGitChangeKind, + val unifiedDiff: String, + val truncated: Boolean, +) + +@Serializable +data class ThreadChangesResultPayload( + @Serializable(with = UuidSerializer::class) val clientRequestID: UUID, + val sessionID: String, + val ok: Boolean, + val errorMessage: String? = null, + val turnEdits: List = emptyList(), + val uncommitted: List = emptyList(), +) + +@Serializable +data class PingPayload( + @Serializable(with = SwiftDateSerializer::class) val t: Instant = Instant.now(), +) + +@Serializable +data class PongPayload( + @Serializable(with = SwiftDateSerializer::class) val t: Instant = Instant.now(), +) + +// MARK: - Polymorphic serializer + +/** + * Encodes/decodes Swift's `{ "type": ..., "data": ... }` tag-then-payload + * shape. Unknown `type` values become [Payload.Unknown] so newer payloads + * don't kill the connection. + */ +object PayloadSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Payload") { + element("type") + element("data") + } + + override fun deserialize(decoder: Decoder): Payload { + val input = decoder as? JsonDecoder + ?: error("Payload requires kotlinx.serialization.json.JsonDecoder") + val obj = input.decodeJsonElement().jsonObject + val type = obj["type"]?.jsonPrimitive?.contentOrNullSafe() + ?: return Payload.Unknown("", obj) + val data = obj["data"] as? JsonObject ?: JsonObject(emptyMap()) + val json = input.json + return when (type) { + "pair_request" -> Payload.PairRequest(json.decodeFromJsonElement(PairRequestPayload.serializer(), data)) + "pair_ack" -> Payload.PairAck(json.decodeFromJsonElement(PairAckPayload.serializer(), data)) + "unpair" -> Payload.Unpair(json.decodeFromJsonElement(UnpairPayload.serializer(), data)) + "request_snapshot" -> Payload.RequestSnapshot(json.decodeFromJsonElement(RequestSnapshotPayload.serializer(), data)) + "snapshot" -> Payload.Snapshot(json.decodeFromJsonElement(SnapshotPayload.serializer(), data)) + "session_update" -> Payload.SessionUpdate(json.decodeFromJsonElement(SessionUpdatePayload.serializer(), data)) + "subscribe_session" -> Payload.SubscribeSession(json.decodeFromJsonElement(SubscribeSessionPayload.serializer(), data)) + "user_message" -> Payload.UserMessage(json.decodeFromJsonElement(UserMessagePayload.serializer(), data)) + "cancel_stream" -> Payload.CancelStream(json.decodeFromJsonElement(CancelStreamPayload.serializer(), data)) + "new_session_request" -> Payload.NewSessionRequest(json.decodeFromJsonElement(NewSessionRequestPayload.serializer(), data)) + "thread_action_request" -> Payload.ThreadActionRequest(json.decodeFromJsonElement(ThreadActionRequestPayload.serializer(), data)) + "load_more_messages" -> Payload.LoadMoreMessages(json.decodeFromJsonElement(LoadMoreMessagesRequestPayload.serializer(), data)) + "more_messages" -> Payload.MoreMessages(json.decodeFromJsonElement(MoreMessagesPayload.serializer(), data)) + "permission_request" -> Payload.PermissionRequest(json.decodeFromJsonElement(PermissionRequestPayload.serializer(), data)) + "permission_response" -> Payload.PermissionResponse(json.decodeFromJsonElement(PermissionResponsePayload.serializer(), data)) + "question_queue" -> Payload.QuestionQueue(json.decodeFromJsonElement(QuestionQueuePayload.serializer(), data)) + "question_answer" -> Payload.QuestionAnswer(json.decodeFromJsonElement(QuestionAnswerPayload.serializer(), data)) + "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)) + "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)) + "run_profile_stop_request" -> Payload.RunProfileStopRequest(json.decodeFromJsonElement(RunProfileStopRequestPayload.serializer(), data)) + "run_task_update" -> Payload.RunTaskUpdate(json.decodeFromJsonElement(RunTaskUpdatePayload.serializer(), data)) + "thread_changes_request" -> Payload.ThreadChangesRequest(json.decodeFromJsonElement(ThreadChangesRequestPayload.serializer(), data)) + "thread_changes_result" -> Payload.ThreadChangesResult(json.decodeFromJsonElement(ThreadChangesResultPayload.serializer(), data)) + "ping" -> Payload.Ping(json.decodeFromJsonElement(PingPayload.serializer(), data)) + "pong" -> Payload.Pong(json.decodeFromJsonElement(PongPayload.serializer(), data)) + else -> Payload.Unknown(type, data) + } + } + + override fun serialize(encoder: Encoder, value: Payload) { + val output = encoder as? JsonEncoder + ?: error("Payload requires kotlinx.serialization.json.JsonEncoder") + val json = output.json + val (typeStr, dataElement) = when (value) { + is Payload.PairRequest -> value.type to json.encodeToJsonElement(PairRequestPayload.serializer(), value.data) + is Payload.PairAck -> value.type to json.encodeToJsonElement(PairAckPayload.serializer(), value.data) + is Payload.Unpair -> value.type to json.encodeToJsonElement(UnpairPayload.serializer(), value.data) + is Payload.RequestSnapshot -> value.type to json.encodeToJsonElement(RequestSnapshotPayload.serializer(), value.data) + is Payload.Snapshot -> value.type to json.encodeToJsonElement(SnapshotPayload.serializer(), value.data) + is Payload.SessionUpdate -> value.type to json.encodeToJsonElement(SessionUpdatePayload.serializer(), value.data) + is Payload.SubscribeSession -> value.type to json.encodeToJsonElement(SubscribeSessionPayload.serializer(), value.data) + is Payload.UserMessage -> value.type to json.encodeToJsonElement(UserMessagePayload.serializer(), value.data) + is Payload.CancelStream -> value.type to json.encodeToJsonElement(CancelStreamPayload.serializer(), value.data) + is Payload.NewSessionRequest -> value.type to json.encodeToJsonElement(NewSessionRequestPayload.serializer(), value.data) + is Payload.ThreadActionRequest -> value.type to json.encodeToJsonElement(ThreadActionRequestPayload.serializer(), value.data) + is Payload.LoadMoreMessages -> value.type to json.encodeToJsonElement(LoadMoreMessagesRequestPayload.serializer(), value.data) + is Payload.MoreMessages -> value.type to json.encodeToJsonElement(MoreMessagesPayload.serializer(), value.data) + is Payload.PermissionRequest -> value.type to json.encodeToJsonElement(PermissionRequestPayload.serializer(), value.data) + is Payload.PermissionResponse -> value.type to json.encodeToJsonElement(PermissionResponsePayload.serializer(), value.data) + is Payload.QuestionQueue -> value.type to json.encodeToJsonElement(QuestionQueuePayload.serializer(), value.data) + is Payload.QuestionAnswer -> value.type to json.encodeToJsonElement(QuestionAnswerPayload.serializer(), value.data) + 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.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) + is Payload.RunProfileStopRequest -> value.type to json.encodeToJsonElement(RunProfileStopRequestPayload.serializer(), value.data) + is Payload.RunTaskUpdate -> value.type to json.encodeToJsonElement(RunTaskUpdatePayload.serializer(), value.data) + is Payload.ThreadChangesRequest -> value.type to json.encodeToJsonElement(ThreadChangesRequestPayload.serializer(), value.data) + is Payload.ThreadChangesResult -> value.type to json.encodeToJsonElement(ThreadChangesResultPayload.serializer(), value.data) + is Payload.Ping -> value.type to json.encodeToJsonElement(PingPayload.serializer(), value.data) + is Payload.Pong -> value.type to json.encodeToJsonElement(PongPayload.serializer(), value.data) + is Payload.Unknown -> value.type to (value.raw ?: JsonObject(emptyMap())) + } + val wire = buildJsonObject { + put("type", JsonPrimitive(typeStr)) + put("data", dataElement) + } + output.encodeJsonElement(wire) + } +} + +private fun JsonPrimitive.contentOrNullSafe(): String? = + if (isString) content else content.takeIf { it.isNotEmpty() && it != "null" } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/RunProfile.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/RunProfile.kt new file mode 100644 index 00000000..aaba837f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/RunProfile.kt @@ -0,0 +1,169 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +/** + * Mirrors Swift `RunProfile` and its three config variants. iOS keeps `bash` as + * the only non-optional config and treats `xcode`/`make` as optional bags that + * coexist with the active `type` — the Android implementation does the same so + * legacy desktops continue to decode cleanly. + */ +@Serializable +enum class RunProfileType { + @SerialName("bash") BASH, + @SerialName("xcode") XCODE, + @SerialName("make") MAKE, +} + +@Serializable +enum class RunStepType { + @SerialName("bash") BASH, +} + +@Serializable +enum class XcodeAction { + @SerialName("build") BUILD, + @SerialName("run") RUN, + @SerialName("test") TEST, + @SerialName("clean") CLEAN, +} + +@Serializable +data class EnvVar( + @Serializable(with = UuidSerializer::class) val id: UUID = UUID.randomUUID(), + val key: String = "", + val value: String = "", +) + +@Serializable +data class EnvironmentPreset( + @Serializable(with = UuidSerializer::class) val id: UUID = UUID.randomUUID(), + val name: String, + val loadFromFile: Boolean = false, + val envFilePath: String? = null, + val useManualKV: Boolean = true, + val manualVars: List = emptyList(), +) + +@Serializable +data class BashRunConfig( + val command: String = "", + val workingDirectory: String = "", + val environments: List = emptyList(), + @Serializable(with = UuidSerializer::class) val activePresetId: UUID? = null, +) + +@Serializable +data class XcodeDestination( + val kind: Kind, + val platform: String, + val name: String, + val udid: String? = null, + val os: String? = null, + val arch: String? = null, + val variant: String? = null, +) { + @Serializable + enum class Kind { + @SerialName("macOS") MAC_OS, + @SerialName("macCatalyst") MAC_CATALYST, + @SerialName("iosSimulator") IOS_SIMULATOR, + @SerialName("iosDevice") IOS_DEVICE, + @SerialName("tvSimulator") TV_SIMULATOR, + @SerialName("tvDevice") TV_DEVICE, + @SerialName("watchSimulator") WATCH_SIMULATOR, + @SerialName("watchDevice") WATCH_DEVICE, + @SerialName("visionSimulator") VISION_SIMULATOR, + @SerialName("visionDevice") VISION_DEVICE, + @SerialName("other") OTHER, + } +} + +@Serializable +data class XcodeRunConfig( + val container: String = "", + val isWorkspace: Boolean = false, + val scheme: String = "", + val configuration: String = "Debug", + val action: XcodeAction = XcodeAction.RUN, + val selectedDestination: XcodeDestination? = null, + val destination: String = "", +) + +@Serializable +data class MakeRunConfig( + val makefile: String = "", + val target: String = "", + val arguments: String = "", +) + +@Serializable +data class RunStep( + @Serializable(with = UuidSerializer::class) val id: UUID = UUID.randomUUID(), + val type: RunStepType = RunStepType.BASH, + val command: String = "", +) + +@Serializable +data class RunProfile( + @Serializable(with = UuidSerializer::class) val id: UUID = UUID.randomUUID(), + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val name: String, + val type: RunProfileType = RunProfileType.BASH, + val bash: BashRunConfig = BashRunConfig(), + val xcode: XcodeRunConfig? = null, + val make: MakeRunConfig? = null, + val beforeSteps: List = emptyList(), + val afterSteps: List = emptyList(), + @Serializable(with = SwiftDateSerializer::class) val createdAt: Instant = Instant.now(), + @Serializable(with = SwiftDateSerializer::class) val updatedAt: Instant = Instant.now(), +) + +@Serializable +data class MobileProjectRunProfiles( + @Serializable(with = UuidSerializer::class) val projectId: UUID, + val profiles: List = emptyList(), +) + +/** + * Mirror of `MobileRunTaskSnapshot`. Status is sent as a string enum on the + * wire; the Swift side bumps the `terminalOutputTail` periodically so this + * struct is the source of truth for the "live tail" we surface in the run + * profiles sheet. + */ +@Serializable +data class MobileRunTaskSnapshot( + @Serializable(with = UuidSerializer::class) val taskId: UUID, + @Serializable(with = UuidSerializer::class) val projectId: UUID, + @Serializable(with = UuidSerializer::class) val profileId: UUID, + val profileName: String, + val status: Status, + val statusLabel: String, + val exitCode: Int? = null, + @Serializable(with = SwiftDateSerializer::class) val startedAt: Instant, + val resolvedCwd: String = "", + val commandPreview: String = "", + val terminalOutputTail: String? = null, +) { + @Serializable + enum class Status { + @SerialName("running") RUNNING, + @SerialName("succeeded") SUCCEEDED, + @SerialName("failed") FAILED, + @SerialName("signaled") SIGNALED, + @SerialName("stopped") STOPPED, + } + + val isRunning: Boolean get() = status == Status.RUNNING +} + +@Serializable +data class MobileWebProxyInfo( + val host: String, + val port: Int, + val username: String, + val password: String, +) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/TodoItem.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/TodoItem.kt new file mode 100644 index 00000000..fe200126 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/TodoItem.kt @@ -0,0 +1,63 @@ +package app.rxlab.rxcode.proto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +@Serializable +data class TodoItem( + val id: Int, + val content: String, + val activeForm: String, + val status: Status, +) { + @Serializable + enum class Status { + @SerialName("pending") PENDING, + @SerialName("in_progress") IN_PROGRESS, + @SerialName("completed") COMPLETED, + } +} + +/** + * Locates the most recent `TodoWrite` tool call across the message list and + * parses its `todos` array into typed [TodoItem]s. Mirrors the iOS + * `TodoExtractor.latest` so the navigation-title progress ring and the + * todo/summary sheet reflect the same data the desktop sees. + */ +object TodoExtractor { + fun latest(messages: List): List? { + for (message in messages.asReversed()) { + for (block in message.blocks.asReversed()) { + val toolCall = block.toolCall ?: continue + if (toolCall.name.equals("TodoWrite", ignoreCase = true)) { + return parse(toolCall.input) + } + } + } + return null + } + + fun parse(input: JsonObject): List { + val array = input["todos"] as? JsonArray ?: return emptyList() + return array.mapIndexedNotNull { index, element -> + val obj = element as? JsonObject ?: return@mapIndexedNotNull null + val content = obj.string("content").orEmpty() + val activeForm = obj.string("activeForm").takeIf { !it.isNullOrEmpty() } ?: content + val status = when (obj.string("status")) { + "in_progress" -> TodoItem.Status.IN_PROGRESS + "completed" -> TodoItem.Status.COMPLETED + else -> TodoItem.Status.PENDING + } + TodoItem(id = index, content = content, activeForm = activeForm, status = status) + } + } + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/relay/RelayClient.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/relay/RelayClient.kt new file mode 100644 index 00000000..a7d2b10f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/relay/RelayClient.kt @@ -0,0 +1,192 @@ +package app.rxlab.rxcode.relay + +import android.util.Log +import app.rxlab.rxcode.identity.DeviceIdentity +import app.rxlab.rxcode.proto.Envelope +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.proto.envelopeOrDeliveryFailure +import app.rxlab.rxcode.proto.EnvelopeOrFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.util.concurrent.TimeUnit +import kotlin.math.min +import kotlin.math.pow + +/** + * Single WebSocket to the relay, keyed by the mobile pubkey. The relay sees only + * [Envelope] frames — payload encryption lives one layer up in `SyncClient`. + * + * Behavioural parity with Swift's `RelayClient`: + * - reconnect with exponential backoff capped at 30s; + * - 25s app-level ping keepalive; + * - failures from the receive loop and the ping handler only schedule one + * reconnect, so we don't stack sockets on the same pubkey. + */ +class RelayClient( + private val identity: DeviceIdentity, + relayUrl: String, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING } + + sealed interface Event { + data class Inbound(val fromHex: String, val envelope: Envelope) : Event + data class DeliveryFailed(val toHex: String) : Event + data class StateChanged(val state: ConnectionState) : Event + } + + private val mutex = Mutex() + private val client = OkHttpClient.Builder() + .pingInterval(25, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + + private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + @Volatile private var socket: WebSocket? = null + @Volatile private var shouldReconnect = false + @Volatile private var reconnectAttempt = 0 + @Volatile private var reconnectJob: Job? = null + @Volatile private var relayUrl: String = relayUrl + + suspend fun setRelayUrl(url: String) = mutex.withLock { + if (this.relayUrl == url) return@withLock + this.relayUrl = url + if (socket != null) { + closeLocally() + openLocked() + } + } + + suspend fun connect() = mutex.withLock { + shouldReconnect = true + if (socket == null) openLocked() + } + + suspend fun disconnect() = mutex.withLock { + shouldReconnect = false + closeLocally() + updateState(ConnectionState.DISCONNECTED) + } + + /** Send a pre-encrypted envelope JSON to the relay. */ + suspend fun sendEnvelope(envelope: Envelope): Boolean { + val ws = socket ?: return false + val raw = RxJson.encodeToString(Envelope.serializer(), envelope) + return ws.send(raw) + } + + private fun openLocked() { + if (!shouldReconnect) return + if (socket != null) return + updateState(ConnectionState.CONNECTING) + val urlWithKey = buildWsUrl(relayUrl, identity.publicKeyHex) ?: run { + Log.e(TAG, "invalid relay URL: $relayUrl") + scheduleReconnect() + return + } + val req = Request.Builder().url(urlWithKey).build() + socket = client.newWebSocket(req, listener) + } + + private fun closeLocally() { + reconnectJob?.cancel() + reconnectJob = null + socket?.cancel() + socket = null + } + + private fun updateState(next: ConnectionState) { + if (_state.value == next) return + _state.value = next + _events.tryEmit(Event.StateChanged(next)) + } + + private fun scheduleReconnect() { + if (!shouldReconnect) return + reconnectAttempt += 1 + val delaySeconds = min(30, 2.0.pow(reconnectAttempt.toDouble()).toInt()) + updateState(ConnectionState.RECONNECTING) + reconnectJob?.cancel() + reconnectJob = scope.launch { + delay(delaySeconds * 1000L) + mutex.withLock { openLocked() } + } + } + + private val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + reconnectAttempt = 0 + updateState(ConnectionState.CONNECTED) + } + + override fun onMessage(webSocket: WebSocket, text: String) = handleRaw(text) + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) = + handleRaw(bytes.utf8()) + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (webSocket !== socket) return + socket = null + scope.launch { mutex.withLock { scheduleReconnect() } } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (webSocket !== socket) return + Log.w(TAG, "websocket failed: ${t.message}") + socket = null + scope.launch { mutex.withLock { scheduleReconnect() } } + } + } + + private fun handleRaw(raw: String) { + when (val parsed = envelopeOrDeliveryFailure(raw)) { + is EnvelopeOrFailure.Env -> _events.tryEmit( + Event.Inbound(parsed.envelope.from, parsed.envelope) + ) + is EnvelopeOrFailure.Failure -> _events.tryEmit( + Event.DeliveryFailed(parsed.notice.to) + ) + null -> Log.w(TAG, "dropping non-envelope frame (${raw.length} chars)") + } + } + + companion object { + private const val TAG = "RelayClient" + + internal fun buildWsUrl(base: String, pubkeyHex: String): String? { + val trimmed = base.trimEnd('/') + val withWs = if (trimmed.endsWith("/ws", ignoreCase = true)) trimmed else "$trimmed/ws" + val sep = if (withWs.contains('?')) '&' else '?' + // Validate scheme. + if (!withWs.startsWith("ws://", true) && !withWs.startsWith("wss://", true)) return null + return "$withWs${sep}pubkey=$pubkeyHex" + } + } +} 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 new file mode 100644 index 00000000..64f9edd1 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -0,0 +1,769 @@ +package app.rxlab.rxcode.state + +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.BranchOpRequestPayload +import app.rxlab.rxcode.proto.CancelStreamPayload +import app.rxlab.rxcode.proto.LoadMoreMessagesRequestPayload +import app.rxlab.rxcode.proto.MobileRunTaskSnapshot +import app.rxlab.rxcode.proto.NewSessionRequestPayload +import app.rxlab.rxcode.proto.PairRequestPayload +import app.rxlab.rxcode.proto.Payload +import app.rxlab.rxcode.proto.PermissionMode +import app.rxlab.rxcode.proto.PermissionResponsePayload +import app.rxlab.rxcode.proto.PongPayload +import app.rxlab.rxcode.proto.QuestionAnswerEntry +import app.rxlab.rxcode.proto.QuestionAnswerPayload +import app.rxlab.rxcode.proto.RequestSnapshotPayload +import app.rxlab.rxcode.proto.RunProfile +import app.rxlab.rxcode.proto.RunProfileMutationRequestPayload +import app.rxlab.rxcode.proto.RunProfileRunRequestPayload +import app.rxlab.rxcode.proto.RunProfileStopRequestPayload +import app.rxlab.rxcode.proto.SessionUpdatePayload +import app.rxlab.rxcode.proto.SubscribeSessionPayload +import app.rxlab.rxcode.proto.ThreadActionRequestPayload +import app.rxlab.rxcode.proto.ThreadChangesRequestPayload +import app.rxlab.rxcode.proto.UserMessagePayload +import app.rxlab.rxcode.pairing.PairingToken +import app.rxlab.rxcode.relay.RelayClient +import app.rxlab.rxcode.store.PairedDesktop +import app.rxlab.rxcode.store.PairingStore +import app.rxlab.rxcode.sync.SyncClient +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.UUID + +/** + * Mirrors iOS `MobileAppState` — owns the [SyncClient], persists pairings, and + * exposes the UI-facing [MobileState] as a single [StateFlow]. + * + * Intent methods (e.g. [sendUserMessage], [cancelStream]) translate UI actions + * into encrypted payloads addressed at the active desktop, and update local + * state optimistically where the iOS app does the same (e.g. injecting a draft + * thread id while waiting for the desktop's authoritative reply). + */ +@HiltViewModel +class MobileAppState @Inject constructor( + private val store: PairingStore, + private val client: SyncClient, +) : ViewModel() { + private val _state = MutableStateFlow(MobileState(relayUrl = defaultRelayUrl())) + val state: StateFlow = _state.asStateFlow() + + private var started = false + private var pairingTimeout: Job? = null + private var pendingThreadChangesId: UUID? = null + + init { + viewModelScope.launch { observeStore() } + viewModelScope.launch { observeSyncEvents() } + } + + fun start() { + if (started) return + started = true + viewModelScope.launch { + // Wire any already-paired peers before opening the socket so the + // first inbound envelope decrypts cleanly. + _state.value.pairedDesktops.forEach { client.addPeer(it.pubkeyHex) } + client.start() + if (_state.value.isPaired) requestSnapshot("client_start") + } + } + + fun handleAppForeground() { + if (!started) return + viewModelScope.launch { client.start() } + } + + fun handleAppBackground() { + if (!started) return + viewModelScope.launch { client.stop() } + } + + // MARK: - Store + + private suspend fun observeStore() { + combine( + store.pairedDesktops, + store.activeDesktopId, + store.relayUrl, + ) { paired, activeId, relay -> + Triple(paired, activeId, relay) + }.collect { (paired, activeId, relay) -> + val active = paired + .firstOrNull { it.id == activeId } + ?.pubkeyHex + ?: paired.firstOrNull()?.pubkeyHex + ?: "" + val resolvedRelay = (relay ?: paired.firstOrNull()?.relayUrl ?: defaultRelayUrl()) + // Sync peers + relay with the new persisted snapshot. + paired.forEach { client.addPeer(it.pubkeyHex) } + if (resolvedRelay != _state.value.relayUrl) client.setRelayUrl(resolvedRelay) + _state.update { it.copy(pairedDesktops = paired, activeDesktopPubkey = active, relayUrl = resolvedRelay) } + } + } + + // MARK: - Inbound + + private suspend fun observeSyncEvents() { + client.events.collect { event -> + when (event) { + is SyncClient.Event.StateChanged -> + _state.update { it.copy(connectionState = event.state) } + + is SyncClient.Event.DeliveryFailed -> + Log.w(TAG, "delivery failed to ${event.toHex.take(12)}") + + is SyncClient.Event.Inbound -> handleInbound(event.fromHex, event.payload) + } + if (event is SyncClient.Event.StateChanged && + event.state == RelayClient.ConnectionState.CONNECTED && + _state.value.isPaired + ) { + requestSnapshot("relay_connected") + } + } + } + + private suspend fun handleInbound(fromHex: String, payload: Payload) { + when (payload) { + is Payload.PairAck -> handlePairAck(fromHex, payload) + is Payload.Unpair -> handleUnpair(fromHex) + is Payload.Snapshot -> handleSnapshot(fromHex, payload) + is Payload.MoreMessages -> handleMoreMessages(fromHex, payload) + is Payload.SessionUpdate -> handleSessionUpdate(fromHex, payload) + is Payload.PermissionRequest -> { + if (!isActiveDesktop(fromHex)) return + _state.update { it.copy(pendingPermission = payload.data) } + } + is Payload.QuestionQueue -> { + if (!isActiveDesktop(fromHex)) return + _state.update { it.copy(pendingQuestions = payload.data.questions) } + } + is Payload.BranchOpResult -> { + if (!isActiveDesktop(fromHex)) return + _state.update { it.copy(pendingBranchOps = it.pendingBranchOps - payload.data.projectID) } + 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.RunProfileResult -> handleRunProfileResult(fromHex, payload.data) + is Payload.RunTaskUpdate -> handleRunTaskUpdate(fromHex, payload.data.task) + is Payload.ThreadChangesResult -> handleThreadChangesResult(fromHex, payload.data) + is Payload.Ping -> { + // Reply with pong to satisfy the desktop's liveness check. + client.send(Payload.Pong(PongPayload()), fromHex) + } + else -> { /* ignored in Phase 1 */ } + } + } + + private suspend fun handlePairAck(fromHex: String, ack: Payload.PairAck) { + Log.w(TAG, "pair_ack received from ${fromHex.take(12)} accepted=${ack.data.accepted}") + pairingTimeout?.cancel() + pairingTimeout = null + if (!ack.data.accepted) { + Log.w(TAG, "pair_ack rejected by desktop ${fromHex.take(12)}: ${ack.data.reason ?: "no reason"}") + _state.update { + it.copy(pairing = PairingStatus.Failed(ack.data.reason ?: "Your Mac declined the pairing request.")) + } + return + } + val desktop = PairedDesktop( + pubkeyHex = fromHex, + displayName = ack.data.desktopName, + pairedAtEpochMs = System.currentTimeMillis(), + lastSeenEpochMs = System.currentTimeMillis(), + relayUrl = _state.value.relayUrl, + ) + store.upsert(desktop) + store.setActive(desktop.id) + _state.update { it.copy(pairing = PairingStatus.Idle) } + client.addPeer(fromHex) + Log.w(TAG, "pairing stored for desktop ${fromHex.take(12)} (${desktop.displayName})") + requestSnapshot("paired") + } + + private suspend fun handleUnpair(fromHex: String) { + val desktop = _state.value.pairedDesktops.firstOrNull { it.pubkeyHex == fromHex } ?: return + store.remove(desktop.id) + client.removePeer(fromHex) + } + + private fun handleSnapshot(fromHex: String, snap: Payload.Snapshot) { + if (!isActiveDesktop(fromHex)) return + _state.update { current -> + val nextMessages = current.messagesBySession.toMutableMap() + val moreSet = current.sessionsWithMoreMessages.toMutableSet() + val loadingMore = current.loadingMoreSessions.toMutableSet() + val active = snap.data.activeSessionID + if (active != null) { + val msgs = snap.data.activeSessionMessages + if (msgs != null) { + nextMessages[active] = msgs + if (snap.data.activeSessionHasMore == true) moreSet.add(active) else moreSet.remove(active) + loadingMore.remove(active) + } else if (nextMessages[active] == null) { + nextMessages[active] = emptyList() + } + } + current.copy( + projects = snap.data.projects, + sessions = snap.data.sessions.sortedWith(SessionSort), + activeSessionID = active ?: current.activeSessionID, + messagesBySession = nextMessages, + sessionsWithMoreMessages = moreSet, + loadingMoreSessions = loadingMore, + loadingThreadMessageSessions = if (active != null && snap.data.activeSessionMessages != null) { + current.loadingThreadMessageSessions - active + } else { + current.loadingThreadMessageSessions + }, + branchBriefings = snap.data.branchBriefings ?: current.branchBriefings, + threadSummaries = snap.data.threadSummaries ?: current.threadSummaries, + projectBranches = snap.data.projectBranches + ?.associateBy { it.projectId } + ?: current.projectBranches, + runProfilesByProject = snap.data.runProfiles + ?.associate { it.projectId to it.profiles } + ?: current.runProfilesByProject, + runTasks = snap.data.runTasks ?: current.runTasks, + desktopWebProxy = snap.data.webProxy ?: current.desktopWebProxy, + hasReceivedInitialSnapshot = true, + ) + } + } + + private fun handleMoreMessages(fromHex: String, more: Payload.MoreMessages) { + if (!isActiveDesktop(fromHex)) return + _state.update { current -> + val sid = current.resolveSessionId(more.data.sessionID) + val existing = current.messagesBySession[sid].orEmpty() + val existingIDs = existing.map { it.id }.toHashSet() + val older = more.data.messages.filter { it.id !in existingIDs } + val nextMessages = current.messagesBySession.toMutableMap().apply { + this[sid] = older + existing + } + val moreSet = current.sessionsWithMoreMessages.toMutableSet().apply { + if (more.data.hasMore) add(sid) else remove(sid) + } + val loadingMore = current.loadingMoreSessions.toMutableSet().apply { remove(sid) } + current.copy( + messagesBySession = nextMessages, + sessionsWithMoreMessages = moreSet, + loadingMoreSessions = loadingMore, + ) + } + } + + private fun handleSessionUpdate(fromHex: String, update: Payload.SessionUpdate) { + if (!isActiveDesktop(fromHex)) return + _state.update { current -> + var next = current + val previous = update.data.previousSessionID + if (previous != null && previous != update.data.sessionID) { + val redirects = current.sessionIDRedirects.toMutableMap() + redirects[previous] = update.data.sessionID + redirects.entries + .filter { it.value == previous } + .forEach { redirects[it.key] = update.data.sessionID } + val msgs = current.messagesBySession.toMutableMap() + val carried = msgs.remove(previous) + if (carried != null) { + val existing = msgs[update.data.sessionID].orEmpty() + msgs[update.data.sessionID] = if (existing.isEmpty()) { + carried + } else { + val existingIDs = existing.map { it.id }.toHashSet() + carried.filter { it.id !in existingIDs } + existing + } + } + val moreSet = current.sessionsWithMoreMessages.toMutableSet().also { + if (it.remove(previous)) it.add(update.data.sessionID) + } + val loadingMore = current.loadingMoreSessions.toMutableSet().also { + if (it.remove(previous)) it.add(update.data.sessionID) + } + val loadingThreadMessages = current.loadingThreadMessageSessions.toMutableSet().also { + if (it.remove(previous)) it.add(update.data.sessionID) + } + next = next.copy( + sessionIDRedirects = redirects, + messagesBySession = msgs, + sessionsWithMoreMessages = moreSet, + loadingMoreSessions = loadingMore, + loadingThreadMessageSessions = loadingThreadMessages, + activeSessionID = if (next.activeSessionID == previous) update.data.sessionID else next.activeSessionID, + sessions = next.sessions.filter { it.id != previous }, + ) + } + + // Per-session summary + streaming/thinking flags. + val summary = update.data.summary + val withSummary = if (summary != null) { + val sessions = next.sessions.toMutableList() + val idx = sessions.indexOfFirst { it.id == summary.id } + if (idx >= 0) sessions[idx] = summary else sessions.add(summary) + next.copy(sessions = sessions.sortedWith(SessionSort)) + } else next + + val thinking = withSummary.thinkingSessions.toMutableSet() + update.data.isThinking?.let { if (it) thinking.add(update.data.sessionID) else thinking.remove(update.data.sessionID) } + if (update.data.isStreaming == false) thinking.remove(update.data.sessionID) + + val msgs = withSummary.messagesBySession.toMutableMap() + val sid = update.data.sessionID + when (update.data.kind) { + SessionUpdatePayload.Kind.MESSAGE_APPENDED -> update.data.message?.let { m -> + msgs[sid] = (msgs[sid].orEmpty() + m) + } + SessionUpdatePayload.Kind.MESSAGE_UPDATED -> update.data.message?.let { m -> + val list = msgs[sid]?.toMutableList() ?: return@let + val idx = list.indexOfFirst { it.id == m.id } + if (idx >= 0) { list[idx] = m; msgs[sid] = list } + } + else -> Unit + } + + val loadingThreadMessages = withSummary.loadingThreadMessageSessions - sid + withSummary.copy( + messagesBySession = msgs, + thinkingSessions = thinking, + loadingThreadMessageSessions = loadingThreadMessages, + ) + } + } + + // MARK: - Intents + + fun requestSnapshot(reason: String = "manual") { + viewModelScope.launch { + val activeHex = _state.value.activeDesktopPubkey + if (activeHex.isEmpty()) return@launch + client.send( + Payload.RequestSnapshot(RequestSnapshotPayload(activeSessionID = _state.value.activeSessionID)), + activeHex, + ) + Log.i(TAG, "snapshot requested ($reason)") + } + } + + fun selectSession(sessionId: String?) { + val resolvedSessionId = sessionId?.let { _state.value.resolveSessionId(it) } + _state.update { + it.copy( + activeSessionID = sessionId, + loadingThreadMessageSessions = resolvedSessionId?.let { id -> setOf(id) } ?: emptySet(), + ) + } + viewModelScope.launch { + val hex = _state.value.activeDesktopPubkey + if (hex.isEmpty()) return@launch + client.send(Payload.SubscribeSession(SubscribeSessionPayload(sessionID = sessionId)), hex) + if (sessionId != null) requestSnapshot("session_selected") + } + } + + fun sendUserMessage(text: String) { + val sessionId = _state.value.activeSessionID ?: return + val resolved = _state.value.resolveSessionId(sessionId) + viewModelScope.launch { + client.send( + Payload.UserMessage( + UserMessagePayload(sessionID = resolved, text = text) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun cancelStream() { + val sessionId = _state.value.activeSessionID ?: return + val resolved = _state.value.resolveSessionId(sessionId) + viewModelScope.launch { + client.send(Payload.CancelStream(CancelStreamPayload(sessionID = resolved)), _state.value.activeDesktopPubkey) + } + } + + fun loadMoreMessages() { + val sid = _state.value.activeSessionID ?: return + val resolved = _state.value.resolveSessionId(sid) + if (!_state.value.sessionsWithMoreMessages.contains(resolved)) return + val first = _state.value.messagesBySession[resolved]?.firstOrNull() ?: return + _state.update { it.copy(loadingMoreSessions = it.loadingMoreSessions + resolved) } + viewModelScope.launch { + client.send( + Payload.LoadMoreMessages( + LoadMoreMessagesRequestPayload( + sessionID = resolved, + beforeMessageID = first.id, + limit = MESSAGE_PAGE_SIZE, + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun startNewSession(projectId: UUID, initialText: String? = null, planMode: Boolean = false, permissionMode: PermissionMode? = null) { + val draftId = draftSessionId(projectId) + _state.update { it.copy(activeSessionID = draftId, messagesBySession = it.messagesBySession + (draftId to emptyList())) } + viewModelScope.launch { + client.send( + Payload.NewSessionRequest( + NewSessionRequestPayload( + projectID = projectId, + initialText = initialText, + planMode = planMode.takeIf { it }, + permissionMode = permissionMode, + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + /** + * Ask the desktop to `git init` the project root. Used by the Briefing + * detail screen when the project's resolved branch is `"unknown"`. The + * desktop replies with `branch_op_result`; the inbound handler clears + * [MobileState.pendingBranchOps] and refreshes the snapshot so the new + * branch info ("main", etc.) appears. + */ + fun initProjectGit(projectId: UUID) { + if (_state.value.pendingBranchOps.contains(projectId)) return + _state.update { it.copy(pendingBranchOps = it.pendingBranchOps + projectId) } + viewModelScope.launch { + client.send( + Payload.BranchOpRequest( + BranchOpRequestPayload( + projectID = projectId, + operation = BranchOpRequestPayload.Operation.INIT_GIT, + branch = "", + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun renameThread(sessionId: String, newTitle: String) = threadAction( + sessionId, ThreadActionRequestPayload.ThreadAction.RENAME, newTitle + ) + + fun archiveThread(sessionId: String) = threadAction( + sessionId, ThreadActionRequestPayload.ThreadAction.ARCHIVE, null + ) + + fun deleteThread(sessionId: String) = threadAction( + sessionId, ThreadActionRequestPayload.ThreadAction.DELETE, null + ) + + private fun threadAction( + sessionId: String, + action: ThreadActionRequestPayload.ThreadAction, + newTitle: String?, + ) { + viewModelScope.launch { + client.send( + Payload.ThreadActionRequest( + ThreadActionRequestPayload(sessionID = sessionId, action = action, newTitle = newTitle) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun respondToPermission(allow: Boolean, denyReason: String? = null) { + val req = _state.value.pendingPermission ?: return + _state.update { it.copy(pendingPermission = null) } + viewModelScope.launch { + client.send( + Payload.PermissionResponse( + PermissionResponsePayload(requestID = req.requestID, allow = allow, denyReason = denyReason) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun answerQuestion(toolUseID: String, answers: List) { + _state.update { it.copy(pendingQuestions = it.pendingQuestions.filterNot { q -> q.toolUseID == toolUseID }) } + viewModelScope.launch { + client.send( + Payload.QuestionAnswer(QuestionAnswerPayload(toolUseID, answers)), + _state.value.activeDesktopPubkey, + ) + } + } + + // MARK: - Run profiles + + private fun handleRunProfileResult( + fromHex: String, + result: app.rxlab.rxcode.proto.RunProfileResultPayload, + ) { + if (!isActiveDesktop(fromHex)) return + if (!result.ok) { + Log.w(TAG, "run profile op failed for project=${result.projectID}: ${result.errorMessage}") + return + } + _state.update { current -> + var next = current + result.profiles?.let { profiles -> + next = next.copy( + runProfilesByProject = next.runProfilesByProject + (result.projectID to profiles), + ) + } + result.task?.let { task -> + next = next.copy(runTasks = mergeRunTask(next.runTasks, task)) + } + next + } + } + + private fun handleRunTaskUpdate(fromHex: String, task: MobileRunTaskSnapshot) { + if (!isActiveDesktop(fromHex)) return + _state.update { it.copy(runTasks = mergeRunTask(it.runTasks, task)) } + } + + private fun mergeRunTask( + existing: List, + task: MobileRunTaskSnapshot, + ): List { + val list = existing.toMutableList() + val idx = list.indexOfFirst { it.taskId == task.taskId } + if (idx >= 0) list[idx] = task else list.add(task) + return list + } + + /** Ask the desktop to start the run profile. */ + fun runRunProfile(projectId: UUID, profileId: UUID) { + viewModelScope.launch { + client.send( + Payload.RunProfileRunRequest( + RunProfileRunRequestPayload(projectID = projectId, profileID = profileId) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + /** Stop the running task (by task id, falling back to profile id). */ + fun stopRunTask(taskId: UUID? = null, projectId: UUID? = null, profileId: UUID? = null) { + viewModelScope.launch { + client.send( + Payload.RunProfileStopRequest( + RunProfileStopRequestPayload( + taskID = taskId, + projectID = projectId, + profileID = profileId, + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + /** Create or update a run profile. The desktop replies with the new list. */ + fun upsertRunProfile(profile: RunProfile) { + viewModelScope.launch { + client.send( + Payload.RunProfileMutationRequest( + RunProfileMutationRequestPayload( + projectID = profile.projectId, + operation = RunProfileMutationRequestPayload.Operation.UPSERT, + profile = profile, + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + fun deleteRunProfile(projectId: UUID, profileId: UUID) { + viewModelScope.launch { + client.send( + Payload.RunProfileMutationRequest( + RunProfileMutationRequestPayload( + projectID = projectId, + operation = RunProfileMutationRequestPayload.Operation.DELETE, + profileID = profileId, + ) + ), + _state.value.activeDesktopPubkey, + ) + } + } + + // MARK: - Thread changes + + /** + * Ask the desktop for the changes (per-turn file edits + uncommitted git + * changes) for [sessionId]. Mirrors iOS `requestThreadChanges`: tracks the + * latest request id so a stale response is dropped, and toggles + * [MobileState.isLoadingThreadChanges] for the sheet's loading UI. + */ + fun requestThreadChanges(sessionId: String) { + val hex = _state.value.activeDesktopPubkey + if (hex.isEmpty()) return + val resolved = _state.value.resolveSessionId(sessionId) + val requestId = UUID.randomUUID() + pendingThreadChangesId = requestId + _state.update { it.copy(isLoadingThreadChanges = true) } + viewModelScope.launch { + val sent = client.send( + Payload.ThreadChangesRequest( + ThreadChangesRequestPayload(clientRequestID = requestId, sessionID = resolved) + ), + hex, + ) + if (!sent && pendingThreadChangesId == requestId) { + pendingThreadChangesId = null + _state.update { it.copy(isLoadingThreadChanges = false) } + } + } + } + + private fun handleThreadChangesResult( + fromHex: String, + result: app.rxlab.rxcode.proto.ThreadChangesResultPayload, + ) { + if (!isActiveDesktop(fromHex)) return + val pending = pendingThreadChangesId ?: return + if (result.clientRequestID != pending) return + pendingThreadChangesId = null + _state.update { it.copy(threadChanges = result, isLoadingThreadChanges = false) } + } + + // MARK: - Pairing + + fun beginPairing(token: PairingToken, displayName: String = defaultDisplayName()) { + Log.w( + TAG, + "beginPairing requested for desktop=${token.desktopName.ifBlank { "unknown" }} " + + "pubkey=${token.desktopPubkeyHex.take(12)} relay=${token.relayURL} " + + "expiresAt=${token.expiresAt} expired=${token.isExpired}", + ) + if (_state.value.pairing is PairingStatus.InProgress) { + Log.w(TAG, "beginPairing ignored because another pairing request is already in progress") + return + } + if (token.isExpired) { + Log.w(TAG, "beginPairing rejected expired token for desktop ${token.desktopPubkeyHex.take(12)}") + _state.update { it.copy(pairing = PairingStatus.Failed("The pairing QR code has expired. Generate a fresh one on your Mac.")) } + return + } + _state.update { it.copy(pairing = PairingStatus.InProgress) } + viewModelScope.launch { + try { + Log.w(TAG, "pairing relay setup: relay=${token.relayURL} desktop=${token.desktopPubkeyHex.take(12)}") + store.setRelayUrl(token.relayURL) + client.setRelayUrl(token.relayURL) + client.addPeer(token.desktopPubkeyHex) + client.start() + + // Wait briefly for the socket to connect; iOS uses 25s. + Log.w(TAG, "waiting for relay connection before sending pair_request") + val connected = withTimeoutOrNull(PAIRING_TIMEOUT_MS) { + client.connectionState.first { it == RelayClient.ConnectionState.CONNECTED } + true + } ?: false + if (!connected) { + Log.w(TAG, "pairing relay connection timed out after ${PAIRING_TIMEOUT_MS}ms") + _state.update { it.copy(pairing = PairingStatus.Failed("Couldn't reach the relay. Make sure your Mac is online and try again.")) } + return@launch + } + + Log.w(TAG, "relay connected; sending pair_request to ${token.desktopPubkeyHex.take(12)}") + val enqueued = client.send( + Payload.PairRequest( + PairRequestPayload( + mobilePubkeyHex = client.identity.publicKeyHex, + displayName = displayName.ifBlank { defaultDisplayName() }, + platform = "Android", + appVersion = BuildConfig.VERSION_NAME, + ) + ), + token.desktopPubkeyHex, + ) + Log.w(TAG, "pair_request enqueue result=$enqueued") + if (!enqueued) { + _state.update { it.copy(pairing = PairingStatus.Failed("Couldn't send the pairing request. Try scanning again.")) } + return@launch + } + + pairingTimeout?.cancel() + pairingTimeout = launch { + delay(PAIRING_TIMEOUT_MS) + if (_state.value.pairing == PairingStatus.InProgress) { + Log.w(TAG, "pair_ack timed out after ${PAIRING_TIMEOUT_MS}ms") + _state.update { it.copy(pairing = PairingStatus.Failed("No response from your Mac. Regenerate the QR code and try again.")) } + } + } + } catch (t: Throwable) { + Log.e(TAG, "beginPairing failed: ${t.message}", t) + _state.update { it.copy(pairing = PairingStatus.Failed("Pairing failed before the request could be sent. Try scanning again.")) } + } + } + } + + fun clearPairingError() { + if (_state.value.pairing is PairingStatus.Failed) { + _state.update { it.copy(pairing = PairingStatus.Idle) } + } + } + + fun switchActiveDesktop(desktop: PairedDesktop) { + viewModelScope.launch { + store.setActive(desktop.id) + desktop.relayUrl?.let { + store.setRelayUrl(it) + client.setRelayUrl(it) + } + requestSnapshot("desktop_switched") + } + } + + fun removeDesktop(desktop: PairedDesktop) { + viewModelScope.launch { + store.remove(desktop.id) + client.removePeer(desktop.pubkeyHex) + } + } + + // MARK: - Helpers + + private fun isActiveDesktop(fromHex: String): Boolean = _state.value.activeDesktopPubkey == fromHex + + companion object { + private const val TAG = "MobileAppState" + private const val PAIRING_TIMEOUT_MS = 25_000L + const val MESSAGE_PAGE_SIZE = 30 + + fun defaultDisplayName(): String = Build.MODEL.ifBlank { "Android" } + + fun defaultRelayUrl(): String = + if (BuildConfig.DEBUG) "ws://10.0.2.2:8787/ws" else "wss://relay.rxlab.app/ws" + } +} + +private object SessionSort : Comparator { + override fun compare(a: app.rxlab.rxcode.proto.SessionSummary, b: app.rxlab.rxcode.proto.SessionSummary): Int { + if (a.isPinned != b.isPinned) return if (a.isPinned) -1 else 1 + return b.updatedAt.compareTo(a.updatedAt) + } +} 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 new file mode 100644 index 00000000..47512e74 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileState.kt @@ -0,0 +1,116 @@ +package app.rxlab.rxcode.state + +import app.rxlab.rxcode.proto.ChatMessage +import app.rxlab.rxcode.proto.MobileBranchBriefing +import app.rxlab.rxcode.proto.MobileRunTaskSnapshot +import app.rxlab.rxcode.proto.MobileThreadSummary +import app.rxlab.rxcode.proto.MobileWebProxyInfo +import app.rxlab.rxcode.proto.PendingQuestionPayload +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.SessionSummary +import app.rxlab.rxcode.proto.ThreadChangesResultPayload +import app.rxlab.rxcode.relay.RelayClient +import app.rxlab.rxcode.store.PairedDesktop +import java.util.UUID + +/** + * Immutable snapshot of every piece of state Phase 1 of the UI cares about. + * Mutated by [MobileAppState] via `update { copy(...) }` so Compose collectors + * see one atomic change per inbound event. + */ +data class MobileState( + val pairedDesktops: List = emptyList(), + val activeDesktopPubkey: String = "", + val relayUrl: String = "", + val connectionState: RelayClient.ConnectionState = RelayClient.ConnectionState.DISCONNECTED, + val pairing: PairingStatus = PairingStatus.Idle, + + val projects: List = emptyList(), + val sessions: List = emptyList(), + val activeSessionID: String? = null, + val messagesBySession: Map> = emptyMap(), + val sessionsWithMoreMessages: Set = emptySet(), + val loadingMoreSessions: Set = emptySet(), + val loadingThreadMessageSessions: Set = emptySet(), + val thinkingSessions: Set = emptySet(), + val sessionIDRedirects: Map = emptyMap(), + + /** Per-branch briefings keyed by `"::"`. */ + val branchBriefings: List = emptyList(), + /** Per-thread summary cards shown inside a briefing detail. */ + val threadSummaries: List = emptyList(), + /** Current + available branches per project, indexed by project id. */ + val projectBranches: Map = emptyMap(), + + val pendingPermission: PermissionRequestPayload? = null, + val pendingQuestions: List = emptyList(), + + /** Project ids with an in-flight branch op (init / switch / create). */ + val pendingBranchOps: Set = emptySet(), + + /** Run profiles synced from the desktop, indexed by project id. */ + val runProfilesByProject: Map> = emptyMap(), + /** All current/recent run tasks broadcast by the desktop. */ + val runTasks: List = emptyList(), + /** + * HTTP proxy exposed by the desktop so the mobile in-app browser can reach + * localhost dev servers running on the Mac. `null` when the desktop predates + * proxy support or hasn't published its endpoint yet. + */ + val desktopWebProxy: MobileWebProxyInfo? = null, + + /** + * Latest thread-changes result returned by the desktop. Keyed by + * `sessionID` on consumption so a stale result from a previously opened + * thread is treated as "not yet loaded" by [ThreadChangesSheet]. + */ + val threadChanges: ThreadChangesResultPayload? = null, + val isLoadingThreadChanges: Boolean = false, + + val hasReceivedInitialSnapshot: Boolean = false, + val lastError: String? = null, +) { + val isPaired: Boolean get() = activeDesktopPubkey.isNotEmpty() + + val activeDesktop: PairedDesktop? + get() = pairedDesktops.firstOrNull { it.pubkeyHex == activeDesktopPubkey } +} + +sealed interface PairingStatus { + object Idle : PairingStatus + object InProgress : PairingStatus + data class Failed(val message: String) : PairingStatus +} + +/** Maps a possibly-stale session id through the optimistic-redirect chain. */ +fun MobileState.resolveSessionId(id: String): String { + var cursor = id + val seen = mutableSetOf() + while (true) { + val next = sessionIDRedirects[cursor] ?: return cursor + if (!seen.add(cursor)) return cursor // cycle guard + cursor = next + } +} + +/** Convenience: messages for the active session, after redirect resolution. */ +fun MobileState.activeMessages(): List { + val id = activeSessionID ?: return emptyList() + return messagesBySession[resolveSessionId(id)].orEmpty() +} + +/** Optimistic placeholder id for a thread we just asked the desktop to create. */ +fun draftSessionId(projectId: UUID): String = "draft-new:$projectId:${UUID.randomUUID()}" + +fun isDraftSessionId(id: String): Boolean = id.startsWith("draft-new:") + +/** Run profiles for [projectId], sorted alphabetically. */ +fun MobileState.runProfilesFor(projectId: UUID): List = + runProfilesByProject[projectId].orEmpty().sortedBy { it.name.lowercase() } + +/** Active + recent run tasks for [projectId], newest first. */ +fun MobileState.runTasksFor(projectId: UUID): List = + runTasks.filter { it.projectId == projectId }.sortedByDescending { it.startedAt } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt new file mode 100644 index 00000000..73e07a35 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairedDesktop.kt @@ -0,0 +1,26 @@ +package app.rxlab.rxcode.store + +import kotlinx.serialization.Serializable + +/** + * One Mac the user has paired with. The `(pubkeyHex, relayUrl)` pair identifies + * the pairing, since the same desktop reached through different relays must be + * tracked as distinct entries (matches iOS `PairedDesktop`). + */ +@Serializable +data class PairedDesktop( + val pubkeyHex: String, + val displayName: String, + val pairedAtEpochMs: Long, + val lastSeenEpochMs: Long? = null, + val relayUrl: String? = null, +) { + val id: String + get() { + val normRelay = (relayUrl ?: "") + .trim() + .lowercase() + .trim('/') + return "$pubkeyHex::$normRelay" + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairingStore.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairingStore.kt new file mode 100644 index 00000000..d4d51d64 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/store/PairingStore.kt @@ -0,0 +1,75 @@ +package app.rxlab.rxcode.store + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import app.rxlab.rxcode.proto.RxJson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer + +private val Context.dataStore by preferencesDataStore(name = "rxcode.pairing") + +/** + * Plaintext storage for paired-desktop metadata + active selection. The mobile + * key itself lives in [app.rxlab.rxcode.identity.DeviceIdentity] (Keystore- + * encrypted prefs); this store only holds non-secret identifiers and labels. + */ +class PairingStore(private val context: Context) { + private val pairedKey = stringPreferencesKey("pairedDesktops.json") + private val activeKey = stringPreferencesKey("activeDesktopId") + private val relayKey = stringPreferencesKey("relayUrl") + + val pairedDesktops: Flow> = + context.dataStore.data.map { decodeList(it[pairedKey]) } + + val activeDesktopId: Flow = + context.dataStore.data.map { it[activeKey] } + + val relayUrl: Flow = + context.dataStore.data.map { it[relayKey] } + + suspend fun upsert(desktop: PairedDesktop) { + context.dataStore.edit { prefs -> + val current = decodeList(prefs[pairedKey]) + val replaced = current + .filterNot { it.id == desktop.id } + .plus(desktop) + prefs[pairedKey] = encodeList(replaced) + } + } + + suspend fun remove(id: String) { + context.dataStore.edit { prefs -> + val current = decodeList(prefs[pairedKey]) + prefs[pairedKey] = encodeList(current.filterNot { it.id == id }) + if (prefs[activeKey] == id) prefs.remove(activeKey) + } + } + + suspend fun setActive(id: String?) { + context.dataStore.edit { prefs -> + if (id == null) prefs.remove(activeKey) else prefs[activeKey] = id + } + } + + suspend fun setRelayUrl(url: String) { + context.dataStore.edit { prefs -> prefs[relayKey] = url } + } + + private val serializer = ListSerializer(PairedDesktop.serializer()) + + private fun encodeList(list: List): String = + RxJson.encodeToString(serializer, list) + + private fun decodeList(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return try { + RxJson.decodeFromString(serializer, raw) + } catch (_: Throwable) { + emptyList() + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/sync/SyncClient.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/sync/SyncClient.kt new file mode 100644 index 00000000..f1e4c6de --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/sync/SyncClient.kt @@ -0,0 +1,122 @@ +package app.rxlab.rxcode.sync + +import android.util.Log +import app.rxlab.rxcode.crypto.Hex +import app.rxlab.rxcode.crypto.SessionCrypto +import app.rxlab.rxcode.identity.DeviceIdentity +import app.rxlab.rxcode.proto.Envelope +import app.rxlab.rxcode.proto.Payload +import app.rxlab.rxcode.proto.RxJson +import app.rxlab.rxcode.relay.RelayClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import java.util.concurrent.ConcurrentHashMap + +/** + * High-level facade over [RelayClient]. Owns the peer key cache and translates + * app intents into encrypted [Payload] envelopes. + * + * One mobile client typically has exactly one paired desktop, but the desktop + * keeps the same shape so the same code can later support multi-mac pairing. + */ +class SyncClient( + val identity: DeviceIdentity, + relayUrl: String, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + sealed interface Event { + data class Inbound(val fromHex: String, val payload: Payload) : Event + data class DeliveryFailed(val toHex: String) : Event + data class StateChanged(val state: RelayClient.ConnectionState) : Event + } + + private val relay = RelayClient(identity, relayUrl, scope) + private val peers = ConcurrentHashMap() + + private val _events = MutableSharedFlow(extraBufferCapacity = 128) + val events: SharedFlow = _events.asSharedFlow() + + val connectionState: StateFlow get() = relay.state + + init { + scope.launch { + relay.events.collect { ev -> + when (ev) { + is RelayClient.Event.StateChanged -> _events.emit(Event.StateChanged(ev.state)) + is RelayClient.Event.DeliveryFailed -> _events.emit(Event.DeliveryFailed(ev.toHex)) + is RelayClient.Event.Inbound -> handleEnvelope(ev.envelope) + } + } + } + } + + fun addPeer(pubkeyHex: String) { + val raw = Hex.decode(pubkeyHex) ?: run { + Log.w(TAG, "ignoring invalid peer hex: $pubkeyHex") + return + } + peers[pubkeyHex] = X25519PublicKeyParameters(raw, 0) + } + + fun removePeer(pubkeyHex: String) { + peers.remove(pubkeyHex) + } + + suspend fun setRelayUrl(url: String) = relay.setRelayUrl(url) + + suspend fun start() = relay.connect() + + suspend fun stop() = relay.disconnect() + + /** Encrypt and send `payload` to one paired peer. Returns true on enqueue. */ + suspend fun send(payload: Payload, toHex: String): Boolean { + val recipient = peers[toHex] ?: run { + Log.w(TAG, "send to unknown peer ${toHex.take(12)}") + return false + } + val plaintext = RxJson.encodeToString(Payload.serializer(), payload).toByteArray(Charsets.UTF_8) + val sealed = SessionCrypto.seal(plaintext, identity.privateKey, recipient) + val envelope = Envelope.build( + toHex = toHex, + fromHex = identity.publicKeyHex, + nonce = sealed.nonce, + ciphertext = sealed.ciphertext, + ) + return relay.sendEnvelope(envelope) + } + + private suspend fun handleEnvelope(env: Envelope) { + val nonce = env.nonceData ?: return + val ct = env.ciphertextData ?: return + val sender = peers[env.from] ?: run { + // The pair_ack arrives before addPeer fires, so accept the + // sender-supplied key on first contact and cache it for replays. + val raw = Hex.decode(env.from) ?: return + X25519PublicKeyParameters(raw, 0).also { peers[env.from] = it } + } + val plaintext = try { + SessionCrypto.open(ct, nonce, identity.privateKey, sender) + } catch (t: Throwable) { + Log.w(TAG, "decrypt failed from ${env.from.take(12)}: ${t.message}") + return + } + val payload = try { + RxJson.decodeFromString(Payload.serializer(), String(plaintext, Charsets.UTF_8)) + } catch (t: Throwable) { + Log.w(TAG, "decode failed from ${env.from.take(12)}: ${t.message}") + return + } + _events.emit(Event.Inbound(env.from, payload)) + } + + companion object { + private const val TAG = "SyncClient" + } +} 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 new file mode 100644 index 00000000..7794071a --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/RxCodeApp.kt @@ -0,0 +1,134 @@ +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.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.briefing.BriefingPaneScreen +import app.rxlab.rxcode.ui.onboarding.OnboardingScreen +import app.rxlab.rxcode.ui.projects.ProjectsPaneScreen +import app.rxlab.rxcode.ui.settings.SettingsScreen +import app.rxlab.rxcode.ui.sync.SyncLoadingView +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.delay + +/** + * Root composable. Adapts top-level navigation to window class via + * [NavigationSuiteScaffold]: bottom NavigationBar on a phone, NavigationRail + * once we have enough horizontal room (foldable open, tablet portrait), + * NavigationDrawer on really wide screens (tablet landscape / desktop). + * + * Each content tab is itself adaptive: Briefing and Projects use + * `ListDetailPaneScaffold` internally so a wide screen shows two panes + * (list + detail) automatically, and a phone collapses to a single pane the + * back gesture pops between. + */ +@Composable +fun RxCodeApp(state: MobileState, viewModel: MobileAppState) { + if (!state.isPaired) { + OnboardingScreen(state = state, viewModel = viewModel) + return + } + + // Splash gate: show SyncLoadingView until the first snapshot lands. After + // ~15 seconds without a snapshot we flip into the timed-out state so the + // user can retry, switch desktops, or pair a new Mac. + var showPairingFromSplash by rememberSaveable { mutableStateOf(false) } + if (showPairingFromSplash) { + OnboardingScreen(state = state, viewModel = viewModel) + return + } + if (!state.hasReceivedInitialSnapshot) { + var timedOut by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.hasReceivedInitialSnapshot, state.activeDesktopPubkey) { + if (!state.hasReceivedInitialSnapshot && !timedOut) { + delay(15_000) + if (!state.hasReceivedInitialSnapshot) timedOut = true + } + } + SyncLoadingView( + isTimedOut = timedOut, + pairedDesktops = state.pairedDesktops, + activeDesktopPubkey = state.activeDesktopPubkey, + onRetry = { + timedOut = false + viewModel.requestSnapshot("user_retry") + }, + onSelectDesktop = { desktop -> + timedOut = false + viewModel.switchActiveDesktop(desktop) + }, + onPairNewDesktop = { showPairingFromSplash = true }, + ) + return + } + + var currentTab by rememberSaveable { mutableStateOf(RootTab.Briefing.name) } + val tab = RootTab.valueOf(currentTab) + + 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 + }, + ) + RootTab.Projects -> ProjectsPaneScreen( + state = state, + viewModel = viewModel, + onSettingsClick = { currentTab = RootTab.Settings.name }, + ) + RootTab.Settings -> SettingsScreen( + state = state, + viewModel = viewModel, + onBack = { currentTab = RootTab.Briefing.name }, + onPairNewMac = { showPairingFromSplash = true }, + ) + } + } +} + +enum class RootTab( + val label: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, +) { + Briefing("Briefing", Icons.Outlined.Description), + Projects("Projects", Icons.Outlined.Folder), + Settings("Settings", Icons.Outlined.Settings); + + companion object { + val allTabs: List = 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 new file mode 100644 index 00000000..f1173396 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingDetailScreen.kt @@ -0,0 +1,469 @@ +package app.rxlab.rxcode.ui.briefing + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.AccountTree +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.Schedule +import androidx.compose.material.icons.automirrored.outlined.TextSnippet +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +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.util.HapticEvent +import app.rxlab.rxcode.ui.util.RxMarkdownText +import app.rxlab.rxcode.ui.util.rememberHaptics +import app.rxlab.rxcode.ui.util.relativeTime + +/** + * Briefing detail screen. Mirrors `MobileBriefingDetailView` on iOS: + * header card with project + branch + relative time, summary card with the + * AI-generated briefing, list of threads on that branch, and a New Thread + * extended FAB. When the branch is `"unknown"`, the header shows an + * Initialize Git button instead of the branch chip — tapping it asks the + * desktop to `git init` the project root. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BriefingDetailScreen( + state: MobileState, + viewModel: MobileAppState, + groupKey: BriefingGroupKey, + onBack: () -> Unit, + onOpenSession: (String) -> Unit, + onNewThread: () -> Unit, + selectedSessionId: String? = null, +) { + val haptics = rememberHaptics() + val group by remember(state.branchBriefings, state.threadSummaries, groupKey) { + derivedStateOf { + groupBriefings( + briefings = state.branchBriefings.filter { + it.projectId == groupKey.projectId && it.branch == groupKey.branch + }, + threads = state.threadSummaries.filter { + it.projectId == groupKey.projectId && it.branch == groupKey.branch + }, + ).firstOrNull() + } + } + val project = remember(state.projects, groupKey.projectId) { + state.projects.firstOrNull { it.id == groupKey.projectId } + } + val streamingByThread = remember(state.sessions) { + state.sessions.associate { it.id to it.isStreaming } + } + val isInitializingGit = groupKey.projectId in state.pendingBranchOps + val isUnknownBranch = groupKey.branch.equals("unknown", ignoreCase = true) + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { Text(project?.name ?: "Briefing") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text("New thread") }, + icon = { Icon(Icons.Outlined.Add, contentDescription = null) }, + onClick = { + haptics.play(HapticEvent.LightTap) + onNewThread() + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, vertical = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item("header") { + HeaderCard( + projectName = project?.name ?: "Unknown Project", + branch = groupKey.branch, + isUnknownBranch = isUnknownBranch, + isInitializingGit = isInitializingGit, + updatedAt = group?.updatedAt, + onInitializeGit = { + haptics.play(HapticEvent.LightTap) + viewModel.initProjectGit(groupKey.projectId) + }, + ) + } + + item("summary") { + SummaryCard(text = group?.briefing?.briefing.orEmpty()) + } + + item("threads-header") { + SectionHeader( + icon = Icons.Outlined.ChatBubbleOutline, + title = "Threads", + count = group?.threads?.size, + ) + } + + val threads = group?.threads.orEmpty() + if (threads.isEmpty()) { + item("threads-empty") { EmptyThreadsCard() } + } else { + items(threads, key = { it.sessionId }) { thread -> + ThreadRow( + thread = thread, + isStreaming = streamingByThread[thread.sessionId] == true, + isSelected = thread.sessionId == selectedSessionId, + onClick = { + haptics.play(HapticEvent.LightTap) + onOpenSession(thread.sessionId) + }, + ) + } + } + } + } +} + +@Composable +private fun HeaderCard( + projectName: String, + branch: String, + isUnknownBranch: Boolean, + isInitializingGit: Boolean, + updatedAt: java.time.Instant?, + onInitializeGit: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(52.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Folder, contentDescription = null) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + projectName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (isUnknownBranch) { + TextButton( + onClick = onInitializeGit, + enabled = !isInitializingGit, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, vertical = 4.dp, + ), + ) { + if (isInitializingGit) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text("Initializing…") + } else { + Icon( + Icons.Outlined.Add, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Initialize Git") + } + } + } else { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(branch) }, + leadingIcon = { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + updatedAt?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Outlined.Schedule, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Text( + relativeTime(it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + if (isUnknownBranch && isInitializingGit) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } + } +} + +@Composable +private fun SummaryCard(text: String) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + Icons.Outlined.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + "Summary", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + if (text.isNotEmpty()) { + RxMarkdownText( + markdown = text, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.AutoMirrored.Outlined.TextSnippet, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Column { + Text( + "No summary yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "A summary will appear after threads complete", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } + } +} + +@Composable +private fun SectionHeader( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + count: Int?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 4.dp), + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp)) + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (count != null && count > 0) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.padding(start = 4.dp), + ) { + Text( + count.toString(), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ThreadRow( + thread: MobileThreadSummary, + isStreaming: Boolean, + isSelected: Boolean, + onClick: () -> Unit, +) { + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + thread.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + ) + if (thread.summary.isNotEmpty()) { + RxMarkdownText( + markdown = thread.summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + ) + } + if (isStreaming) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.shapes.small, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + ) + Text( + "Response in progress", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } else { + Text( + relativeTime(thread.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } +} + +@Composable +private fun EmptyThreadsCard() { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(28.dp), + ) + Column { + Text( + "No threads yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Start a conversation from your Mac", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt new file mode 100644 index 00000000..7372564f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingGrouping.kt @@ -0,0 +1,70 @@ +package app.rxlab.rxcode.ui.briefing + +import app.rxlab.rxcode.proto.MobileBranchBriefing +import app.rxlab.rxcode.proto.MobileThreadSummary +import java.time.Instant +import java.util.UUID + +/** + * Same key shape iOS uses: `{ projectId, branch }`. Stable identifier for a + * single briefing card and for selection in the tablet pane layout. + */ +data class BriefingGroupKey(val projectId: UUID, val branch: String) { + val id: String get() = "$projectId::$branch" +} + +/** + * One project+branch row, paired with whatever briefing text and thread cards + * the desktop sent for it. Mirrors Swift `GroupedBriefing`. + */ +data class GroupedBriefing( + val projectId: UUID, + val branch: String, + val briefing: MobileBranchBriefing?, + val threads: List, + val updatedAt: Instant, +) { + val id: String get() = "$projectId::$branch" + val key: BriefingGroupKey get() = BriefingGroupKey(projectId, branch) +} + +/** + * Combine the desktop's per-branch briefings with the per-thread summaries + * into one card per (project, branch). `updatedAt` is the most recent of + * either source so cards stay sorted by activity. Mirrors Swift + * `groupBriefings(briefings:threads:)`. + */ +fun groupBriefings( + briefings: List, + threads: List, +): List { + val buckets = HashMap() + + for (briefing in briefings) { + val key = "${briefing.projectId}::${briefing.branch}" + val existing = buckets[key] + buckets[key] = GroupedBriefing( + projectId = briefing.projectId, + branch = briefing.branch, + briefing = briefing, + threads = existing?.threads ?: emptyList(), + updatedAt = maxOf(briefing.updatedAt, existing?.updatedAt ?: Instant.EPOCH), + ) + } + + for (thread in threads) { + val key = "${thread.projectId}::${thread.branch}" + val existing = buckets[key] + val mergedThreads = (existing?.threads.orEmpty() + thread) + .sortedByDescending { it.updatedAt } + buckets[key] = GroupedBriefing( + projectId = thread.projectId, + branch = thread.branch, + briefing = existing?.briefing, + threads = mergedThreads, + updatedAt = maxOf(thread.updatedAt, existing?.updatedAt ?: Instant.EPOCH), + ) + } + + return buckets.values.sortedByDescending { it.updatedAt } +} 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 new file mode 100644 index 00000000..640df30a --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingPane.kt @@ -0,0 +1,381 @@ +package app.rxlab.rxcode.ui.briefing + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewSidebar +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.chat.ChatScreen +import java.util.UUID + +/** + * Briefing mirrors the Projects foldable layout. The root navigation rail is + * the first column; this screen owns the two content columns: briefing + * list/detail on the left and selected thread messages on the right. + */ +@Suppress("UNUSED_PARAMETER") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun BriefingPaneScreen( + state: MobileState, + viewModel: MobileAppState, + onOpenSession: (String) -> Unit, + onNewThread: (projectId: java.util.UUID) -> Unit, +) { + BriefingContentSplitPane( + state = state, + viewModel = viewModel, + ) +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun BriefingContentSplitPane( + state: MobileState, + viewModel: MobileAppState, +) { + var selectedGroupKey by rememberSaveable(stateSaver = briefingGroupKeySaver) { + mutableStateOf(null) + } + var selectedSessionId by rememberSaveable { mutableStateOf(null) } + + val currentKey = selectedGroupKey + LaunchedEffect(state.branchBriefings, state.threadSummaries, currentKey) { + if (currentKey != null) { + val stillExists = state.branchBriefings.any { + it.projectId == currentKey.projectId && it.branch == currentKey.branch + } || state.threadSummaries.any { + it.projectId == currentKey.projectId && it.branch == currentKey.branch + } + if (!stillExists) { + selectedGroupKey = null + selectedSessionId = null + } + } + } + + BackHandler(enabled = selectedSessionId != null || selectedGroupKey != null) { + when { + selectedSessionId != null -> { + selectedSessionId = null + viewModel.selectSession(null) + } + selectedGroupKey != null -> selectedGroupKey = null + } + } + + val paneDirective = calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth( + currentWindowAdaptiveInfo(), + ) + val shouldUseSplitLayout = paneDirective.maxHorizontalPartitions >= 2 + + BoxWithConstraints(Modifier.fillMaxSize()) { + if (shouldUseSplitLayout) { + WideBriefingPane( + state = state, + viewModel = viewModel, + selectedGroupKey = selectedGroupKey, + onSelectGroup = { + selectedGroupKey = it + selectedSessionId = null + viewModel.selectSession(null) + }, + selectedSessionId = selectedSessionId, + onSelectSession = { + viewModel.selectSession(it) + selectedSessionId = it + }, + onClearGroup = { + selectedGroupKey = null + selectedSessionId = null + viewModel.selectSession(null) + }, + onClearSession = { + selectedSessionId = null + viewModel.selectSession(null) + }, + availableWidth = maxWidth, + ) + return@BoxWithConstraints + } + + CompactBriefingPane( + state = state, + viewModel = viewModel, + selectedGroupKey = selectedGroupKey, + onSelectGroup = { + selectedGroupKey = it + selectedSessionId = null + viewModel.selectSession(null) + }, + selectedSessionId = selectedSessionId, + onSelectSession = { + viewModel.selectSession(it) + selectedSessionId = it + }, + onClearGroup = { + selectedGroupKey = null + selectedSessionId = null + viewModel.selectSession(null) + }, + onClearSession = { + selectedSessionId = null + viewModel.selectSession(null) + }, + ) + } +} + +@Composable +private fun CompactBriefingPane( + state: MobileState, + viewModel: MobileAppState, + selectedGroupKey: BriefingGroupKey?, + onSelectGroup: (BriefingGroupKey) -> Unit, + selectedSessionId: String?, + onSelectSession: (String) -> Unit, + onClearGroup: () -> Unit, + onClearSession: () -> Unit, +) { + val sid = selectedSessionId + val key = selectedGroupKey + when { + sid != null -> { + ChatScreen( + state = state, + viewModel = viewModel, + sessionId = sid, + onBack = onClearSession, + ) + } + key != null -> { + BriefingDetailScreen( + state = state, + viewModel = viewModel, + groupKey = key, + onBack = onClearGroup, + onOpenSession = onSelectSession, + onNewThread = { + viewModel.startNewSession(key.projectId, planMode = false) + state.activeSessionID?.let { onSelectSession(it) } + }, + selectedSessionId = selectedSessionId, + ) + } + else -> { + BriefingScreen( + state = state, + viewModel = viewModel, + onOpenGroup = onSelectGroup, + selectedGroupKey = selectedGroupKey, + ) + } + } +} + +@Composable +private fun WideBriefingPane( + state: MobileState, + viewModel: MobileAppState, + selectedGroupKey: BriefingGroupKey?, + onSelectGroup: (BriefingGroupKey) -> Unit, + selectedSessionId: String?, + onSelectSession: (String) -> Unit, + onClearGroup: () -> Unit, + onClearSession: () -> Unit, + availableWidth: Dp, +) { + var isDetailExpanded by rememberSaveable { mutableStateOf(false) } + + BackHandler(enabled = isDetailExpanded) { + isDetailExpanded = false + } + + val contentWidth = (availableWidth * 0.44f).coerceIn(300.dp, 480.dp) + + Row(Modifier.fillMaxSize()) { + if (!isDetailExpanded) { + Box( + modifier = Modifier + .width(contentWidth) + .fillMaxHeight(), + ) { + val key = selectedGroupKey + if (key != null) { + BriefingDetailScreen( + state = state, + viewModel = viewModel, + groupKey = key, + onBack = onClearGroup, + onOpenSession = onSelectSession, + onNewThread = { + viewModel.startNewSession(key.projectId, planMode = false) + state.activeSessionID?.let { onSelectSession(it) } + }, + selectedSessionId = selectedSessionId, + ) + } else { + BriefingScreen( + state = state, + viewModel = viewModel, + onOpenGroup = onSelectGroup, + selectedGroupKey = selectedGroupKey, + ) + } + } + VerticalDivider() + } + Box(Modifier.weight(1f).fillMaxHeight()) { + val sid = selectedSessionId + if (sid != null) { + ChatScreen( + state = state, + viewModel = viewModel, + sessionId = sid, + onBack = onClearSession, + isDetailExpanded = isDetailExpanded, + onToggleDetailExpanded = { isDetailExpanded = !isDetailExpanded }, + ) + } else { + BriefingMessagePlaceholder( + modifier = Modifier.fillMaxSize(), + isDetailExpanded = isDetailExpanded, + onToggleDetailExpanded = { isDetailExpanded = !isDetailExpanded }, + ) + } + } + } +} + +@Composable +private fun BriefingDetailPlaceholder(modifier: Modifier = Modifier) { + Surface(modifier = modifier, color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(56.dp), + ) + Text( + "Select a briefing", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Tap a project on the left to view its summary and threads.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + +@Composable +private fun BriefingMessagePlaceholder( + modifier: Modifier = Modifier, + isDetailExpanded: Boolean? = null, + onToggleDetailExpanded: (() -> Unit)? = null, +) { + Surface(modifier = modifier, color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center) { + if (isDetailExpanded != null && onToggleDetailExpanded != null) { + IconButton( + onClick = onToggleDetailExpanded, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + Icon( + Icons.AutoMirrored.Outlined.ViewSidebar, + contentDescription = if (isDetailExpanded) "Show content column" else "Collapse content column", + ) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(56.dp), + ) + Text( + "Select a thread", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Pick a thread from the briefing to view its messages.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + +private fun Dp.coerceIn(minimumValue: Dp, maximumValue: Dp): Dp { + return when { + this < minimumValue -> minimumValue + this > maximumValue -> maximumValue + else -> this + } +} + +private val briefingGroupKeySaver: Saver = Saver( + save = { key -> key?.let { "${it.projectId}::${it.branch}" } ?: "" }, + restore = { raw -> + if (raw.isEmpty()) { + null + } else { + val separator = raw.indexOf("::") + if (separator <= 0) { + null + } else { + val projectId = runCatching { UUID.fromString(raw.substring(0, separator)) }.getOrNull() + val branch = raw.substring(separator + 2) + projectId?.let { BriefingGroupKey(it, branch) } + } + } + }, +) 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 new file mode 100644 index 00000000..6fd0907a --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/briefing/BriefingScreen.kt @@ -0,0 +1,467 @@ +package app.rxlab.rxcode.ui.briefing + +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountTree +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.FilterAltOff +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.RxMarkdownText +import app.rxlab.rxcode.ui.util.rememberHaptics +import app.rxlab.rxcode.ui.util.relativeTime +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.UUID + +/** + * Briefing tab — list of per-branch briefing cards. Mirrors + * `MobileBriefingView` on iOS: filter by project / branch, pull to refresh, + * tap a card to drill into [BriefingDetailScreen]. + * + * The card layout uses an adaptive 2-column grid on wider screens so tablets + * and unfolded foldables fan out, but stays single-column on phones. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BriefingScreen( + state: MobileState, + viewModel: MobileAppState, + onOpenGroup: (BriefingGroupKey) -> Unit, + selectedGroupKey: BriefingGroupKey? = null, +) { + val haptics = rememberHaptics() + val scope = rememberCoroutineScope() + var selectedProjectIds by remember { mutableStateOf(emptySet()) } + var showAllBranches by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + var refreshing by remember { mutableStateOf(false) } + + val projectsById by remember(state.projects) { + derivedStateOf { state.projects.associateBy { it.id } } + } + val activeJobsByProject by remember(state.sessions) { + derivedStateOf { + state.sessions.asSequence() + .filter { it.isStreaming } + .groupingBy { it.projectId } + .eachCount() + } + } + val allGroups by remember(state.branchBriefings, state.threadSummaries) { + derivedStateOf { groupBriefings(state.branchBriefings, state.threadSummaries) } + } + val visibleGroups by remember(allGroups, selectedProjectIds, showAllBranches, state.projectBranches) { + derivedStateOf { + val filteredByProject = if (selectedProjectIds.isEmpty()) { + allGroups + } else { + allGroups.filter { it.projectId in selectedProjectIds } + } + if (showAllBranches) { + filteredByProject + } else { + filteredByProject.filter { group -> + val current = state.projectBranches[group.projectId]?.currentBranch + ?: return@filter true + group.branch == current + } + } + } + } + val projectsWithData by remember(allGroups, state.projects) { + derivedStateOf { + val ids = allGroups.map { it.projectId }.toSet() + state.projects.filter { it.id in ids } + } + } + val hasAnyData = state.branchBriefings.isNotEmpty() || state.threadSummaries.isNotEmpty() + val isFilterActive = showAllBranches || selectedProjectIds.isNotEmpty() + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { Text("Briefing") }, + actions = { + if (hasAnyData) { + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + if (isFilterActive) Icons.Outlined.FilterAlt else Icons.Outlined.FilterAltOff, + contentDescription = "Filter", + ) + } + BriefingFilterMenu( + expanded = menuOpen, + onDismiss = { menuOpen = false }, + showAllBranches = showAllBranches, + onSetShowAllBranches = { + showAllBranches = it + haptics.play(HapticEvent.Selection) + }, + projects = projectsWithData, + selectedProjectIds = selectedProjectIds, + onToggleProject = { id -> + selectedProjectIds = if (id in selectedProjectIds) { + selectedProjectIds - id + } else { + selectedProjectIds + id + } + haptics.play(HapticEvent.Selection) + }, + onClearProjects = { + selectedProjectIds = emptySet() + haptics.play(HapticEvent.Selection) + }, + ) + } + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + haptics.play(HapticEvent.LightTap) + refreshing = true + viewModel.requestSnapshot("pull_to_refresh") + scope.launch { + delay(800) + refreshing = false + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (visibleGroups.isEmpty()) { + BriefingEmptyState( + hasAnyData = hasAnyData, + showAllBranches = showAllBranches, + modifier = Modifier.fillMaxSize(), + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 320.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, vertical = 16.dp, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(visibleGroups, key = { it.id }) { group -> + BriefingCard( + group = group, + project = projectsById[group.projectId], + activeJobCount = activeJobsByProject[group.projectId] ?: 0, + isSelected = group.key == selectedGroupKey, + onClick = { + haptics.play(HapticEvent.LightTap) + onOpenGroup(group.key) + }, + ) + } + } + } + } + } +} + +@Composable +private fun BriefingCard( + group: GroupedBriefing, + project: Project?, + activeJobCount: Int, + isSelected: Boolean, + onClick: () -> Unit, +) { + val isUnknownBranch = group.branch.equals("unknown", ignoreCase = true) + ElevatedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Folder, contentDescription = null) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + project?.name ?: "Unknown Project", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + if (isUnknownBranch) "Initialize Git" else group.branch, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + + val summary = group.briefing?.briefing.orEmpty() + if (summary.isNotEmpty()) { + RxMarkdownText( + markdown = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + ) + } else { + Text( + "No summary available yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + + HorizontalDivider(color = DividerDefaults.color.copy(alpha = 0.4f)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (activeJobCount > 0) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text("$activeJobCount active") }, + leadingIcon = { + Box( + Modifier + .size(8.dp) + .background( + MaterialTheme.colorScheme.primary, + CircleShape, + ) + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledLeadingIconContentColor = MaterialTheme.colorScheme.primary, + ), + ) + } + if (group.threads.isNotEmpty()) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(group.threads.size.toString()) }, + leadingIcon = { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + }, + ) + } + Spacer(Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon( + Icons.Outlined.Schedule, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Text( + relativeTime(group.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } +} + +@Composable +private fun BriefingFilterMenu( + expanded: Boolean, + onDismiss: () -> Unit, + showAllBranches: Boolean, + onSetShowAllBranches: (Boolean) -> Unit, + projects: List, + selectedProjectIds: Set, + onToggleProject: (UUID) -> Unit, + onClearProjects: () -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Text( + "Branches", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + DropdownMenuItem( + text = { Text("Current branch") }, + onClick = { onSetShowAllBranches(false); onDismiss() }, + trailingIcon = { CheckIfSelected(!showAllBranches) }, + ) + DropdownMenuItem( + text = { Text("All branches") }, + onClick = { onSetShowAllBranches(true); onDismiss() }, + trailingIcon = { CheckIfSelected(showAllBranches) }, + ) + if (projects.size > 1) { + HorizontalDivider() + Text( + "Projects", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + DropdownMenuItem( + text = { Text("All projects") }, + onClick = { onClearProjects() }, + trailingIcon = { CheckIfSelected(selectedProjectIds.isEmpty()) }, + ) + projects.forEach { project -> + DropdownMenuItem( + text = { Text(project.name) }, + onClick = { onToggleProject(project.id) }, + trailingIcon = { CheckIfSelected(project.id in selectedProjectIds) }, + ) + } + } + } +} + +@Composable +private fun CheckIfSelected(selected: Boolean) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun BriefingEmptyState( + hasAnyData: Boolean, + showAllBranches: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier, contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(24.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + if (hasAnyData) Icons.Outlined.FilterAltOff else Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(34.dp), + ) + } + } + Text( + if (hasAnyData) "Nothing to Show" else "No Briefings Yet", + style = MaterialTheme.typography.titleMedium, + ) + Text( + when { + !hasAnyData -> "Briefings appear here after threads finish on your Mac." + showAllBranches -> "No briefings match the selected projects." + else -> "No briefings for the current branch. Choose All branches to see other branches." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/BrowserUrlDetector.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/BrowserUrlDetector.kt new file mode 100644 index 00000000..9a26bd92 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/BrowserUrlDetector.kt @@ -0,0 +1,59 @@ +package app.rxlab.rxcode.ui.browser + +import android.net.Uri +import app.rxlab.rxcode.proto.MobileWebProxyInfo + +/** + * Mirrors iOS `MobileBrowserURLDetector`. The desktop publishes an HTTP proxy + * (host/port + basic-auth) in every snapshot; on mobile we route requests to + * that proxy and additionally rewrite localhost dev-server URLs through the + * desktop's `/__rxcode_browser` bootstrap so the WebView lands on the right + * origin even when it can't reach `127.0.0.1` directly. + */ +object BrowserUrlDetector { + private val URL_REGEX = Regex("""https?://[^\s<>"')\]]+""") + + fun detect(texts: List): String? { + val candidates = texts.asReversed().flatMap { text -> + URL_REGEX.findAll(text).map { match -> + match.value.trimEnd('.', ',', ';', ':', '!', '?') + } + } + return candidates.firstOrNull(::isLocalDevURL) ?: candidates.firstOrNull() + } + + fun isLocalDevURL(url: String): Boolean { + val host = runCatching { Uri.parse(url).host?.lowercase() }.getOrNull() ?: return false + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" + } + + /** + * Wrap a localhost dev URL in the desktop's reverse-proxy bootstrap. The + * bootstrap rewrites links/forms so the user can browse same-origin pages + * without leaving the proxy domain. + */ + fun desktopProxyBootstrapURL(url: String, proxyInfo: MobileWebProxyInfo?): String { + if (proxyInfo == null || !isLocalDevURL(url)) return url + val parsed = runCatching { Uri.parse(url) }.getOrNull() ?: return url + if (parsed.scheme?.lowercase() != "http") return url + return Uri.Builder() + .scheme("http") + .encodedAuthority("${proxyInfo.host}:${proxyInfo.port}") + .path("/__rxcode_browser") + .appendQueryParameter("target", url) + .appendQueryParameter("token", proxyInfo.password) + .build() + .toString() + } + + /** Reverse of [desktopProxyBootstrapURL] — strip the proxy wrapper. */ + fun userFacingURL(current: String, proxyInfo: MobileWebProxyInfo?): String { + if (proxyInfo == null) return current + val parsed = runCatching { Uri.parse(current) }.getOrNull() ?: return current + if (parsed.host != proxyInfo.host || parsed.port != proxyInfo.port) return current + if (parsed.path == "/__rxcode_browser") { + return parsed.getQueryParameter("target") ?: current + } + return current + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/InAppBrowserScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/InAppBrowserScreen.kt new file mode 100644 index 00000000..6a2aa1dd --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/browser/InAppBrowserScreen.kt @@ -0,0 +1,291 @@ +package app.rxlab.rxcode.ui.browser + +import android.annotation.SuppressLint +import android.os.Build +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.ProxyConfig +import androidx.webkit.ProxyController +import androidx.webkit.WebViewFeature +import app.rxlab.rxcode.proto.MobileWebProxyInfo +import java.net.URLEncoder +import java.util.concurrent.Executor + +/** + * Full-screen WebView wrapper used by the chat-detail "Open in Browser" + * action. Mirrors the iOS `MobileInAppBrowserView`: address bar with the + * user-facing URL (proxy wrapper hidden), reload + close affordances, and a + * bottom progress bar. The desktop's `MobileWebProxyInfo` is installed via + * AndroidX `ProxyController` so localhost dev servers running on the Mac are + * reachable from the phone. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun InAppBrowserScreen( + initialUrl: String?, + proxyInfo: MobileWebProxyInfo?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val initialLoaded = remember(initialUrl, proxyInfo) { + initialUrl?.let { BrowserUrlDetector.desktopProxyBootstrapURL(it, proxyInfo) } + } + var addressText by remember(initialUrl) { mutableStateOf(initialUrl.orEmpty()) } + var loadingProgress by remember { mutableStateOf(0) } + var isLoading by remember { mutableStateOf(false) } + var lastError by remember { mutableStateOf(null) } + var pendingLoad by remember { mutableStateOf(initialLoaded) } + var webView by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + InstallWebProxy(proxyInfo) + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxSize()) { + BrowserTopBar( + addressText = addressText, + onAddressChange = { addressText = it }, + onSubmit = { + val target = normalizeAddress(addressText) + val routed = BrowserUrlDetector.desktopProxyBootstrapURL(target, proxyInfo) + pendingLoad = routed + webView?.loadUrl(routed) + }, + onReload = { webView?.reload() }, + onClose = onDismiss, + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth(), + ) + if (isLoading) { + LinearProgressIndicator( + progress = { loadingProgress / 100f }, + modifier = Modifier.fillMaxWidth().height(2.dp), + ) + } + Box(Modifier.weight(1f)) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.setSupportZoom(true) + settings.builtInZoomControls = true + settings.displayZoomControls = false + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean = false + + override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + isLoading = true + lastError = null + url?.let { + addressText = BrowserUrlDetector.userFacingURL(it, proxyInfo) + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + isLoading = false + url?.let { + addressText = BrowserUrlDetector.userFacingURL(it, proxyInfo) + } + } + + override fun onReceivedHttpAuthRequest( + view: WebView?, + handler: android.webkit.HttpAuthHandler?, + host: String?, + realm: String?, + ) { + if (proxyInfo != null && host == proxyInfo.host) { + handler?.proceed(proxyInfo.username, proxyInfo.password) + } else { + handler?.cancel() + } + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + loadingProgress = newProgress + } + } + webView = this + pendingLoad?.let { loadUrl(it); pendingLoad = null } + } + }, + ) + if (initialLoaded == null) { + EmptyBrowserState() + } + } + } + } + + DisposableEffect(Unit) { + onDispose { webView?.destroy() } + } + LaunchedEffect(initialLoaded) { + if (initialLoaded != null && webView != null && pendingLoad != null) { + webView?.loadUrl(initialLoaded) + pendingLoad = null + } + } +} + +@Composable +private fun BrowserTopBar( + addressText: String, + onAddressChange: (String) -> Unit, + onSubmit: () -> Unit, + onReload: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onClose) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Close") + } + TextField( + value = addressText, + onValueChange = onAddressChange, + singleLine = true, + modifier = Modifier + .weight(1f) + .heightIn(min = 44.dp), + shape = RoundedCornerShape(22.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + capitalization = KeyboardCapitalization.None, + autoCorrect = false, + ), + keyboardActions = KeyboardActions(onGo = { onSubmit() }), + ) + IconButton(onClick = onReload) { + Icon(Icons.Outlined.Refresh, contentDescription = "Reload") + } + } + } +} + +@Composable +private fun EmptyBrowserState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Type a URL to open", style = MaterialTheme.typography.titleMedium) + Text( + "Localhost dev servers running on your Mac are routed through the paired desktop's proxy.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Installs the desktop's HTTP proxy on the WebView via AndroidX + * [ProxyController]. The controller targets the global WebView process — we + * scope its lifetime to the composition so the proxy is removed when the + * browser dismisses. Falls back to direct connection on devices that lack + * `WebViewFeature.PROXY_OVERRIDE` support. + */ +@Composable +private fun InstallWebProxy(proxyInfo: MobileWebProxyInfo?) { + DisposableEffect(proxyInfo) { + if (proxyInfo != null && WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { + val executor = Executor { it.run() } + val config = ProxyConfig.Builder() + .addProxyRule("${proxyInfo.host}:${proxyInfo.port}") + .addDirect() + .build() + ProxyController.getInstance().setProxyOverride(config, executor, {}) + } + onDispose { + if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { + runCatching { ProxyController.getInstance().clearProxyOverride({ it.run() }, {}) } + } + } + } +} + +private fun normalizeAddress(input: String): String { + val trimmed = input.trim() + if (trimmed.isEmpty()) return trimmed + if (trimmed.contains("://")) return trimmed + if (trimmed.contains(' ') || !trimmed.contains('.')) { + return "https://www.google.com/search?q=" + + URLEncoder.encode(trimmed, Charsets.UTF_8.name()) + } + return "https://$trimmed" +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt new file mode 100644 index 00000000..7509c62f --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt @@ -0,0 +1,1229 @@ +package app.rxlab.rxcode.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.automirrored.outlined.ViewSidebar +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Difference +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.QuestionAnswer +import androidx.compose.material.icons.outlined.QueuePlayNext +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.runtime.snapshotFlow +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.ChatMessage +import app.rxlab.rxcode.proto.MessageBlock +import app.rxlab.rxcode.proto.PendingQuestionPayload +import app.rxlab.rxcode.proto.Role +import app.rxlab.rxcode.proto.ToolCall +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.state.isDraftSessionId +import app.rxlab.rxcode.state.resolveSessionId +import app.rxlab.rxcode.state.runProfilesFor +import app.rxlab.rxcode.state.runTasksFor +import app.rxlab.rxcode.ui.browser.BrowserUrlDetector +import app.rxlab.rxcode.ui.browser.InAppBrowserScreen +import app.rxlab.rxcode.proto.RunProfile +import app.rxlab.rxcode.ui.sheets.FileDiffSheet +import app.rxlab.rxcode.ui.sheets.PermissionApprovalSheet +import app.rxlab.rxcode.ui.sheets.QuestionSheet +import app.rxlab.rxcode.ui.sheets.QueuedMessagesSheet +import app.rxlab.rxcode.ui.sheets.RunProfileEditorSheet +import app.rxlab.rxcode.ui.sheets.RunProfilesSheet +import app.rxlab.rxcode.ui.sheets.ThreadChangesSheet +import app.rxlab.rxcode.ui.sheets.ThreadTodoSheet +import app.rxlab.rxcode.ui.sheets.TodoProgressBadge +import app.rxlab.rxcode.ui.sheets.ToolCallSheet +import app.rxlab.rxcode.proto.TodoExtractor +import app.rxlab.rxcode.proto.TodoItem +import app.rxlab.rxcode.ui.sheets.newBashRunProfile +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.RxMarkdownText +import app.rxlab.rxcode.ui.util.rememberHaptics +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.delay +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +private val compactJson = Json { prettyPrint = false } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + state: MobileState, + viewModel: MobileAppState, + sessionId: String, + onBack: () -> Unit, + isDetailExpanded: Boolean? = null, + onToggleDetailExpanded: (() -> Unit)? = null, +) { + val resolvedId = remember(sessionId, state.sessionIDRedirects) { state.resolveSessionId(sessionId) } + val messages = state.messagesBySession[resolvedId].orEmpty() + val session = state.sessions.firstOrNull { it.id == resolvedId } + val isStreaming = session?.isStreaming == true + val isThinking = state.thinkingSessions.contains(resolvedId) + val isDraftSession = isDraftSessionId(resolvedId) + val isThreadLoadingMessages = state.loadingThreadMessageSessions.contains(resolvedId) + var openToolCall by remember { mutableStateOf(null) } + var openQuestion by remember { mutableStateOf(null) } + var showQueuedSheet by remember { mutableStateOf(false) } + var openEditPreview by remember { mutableStateOf(null) } + var menuExpanded by remember { mutableStateOf(false) } + var showRunProfiles by remember { mutableStateOf(false) } + var editingProfile by remember { mutableStateOf(null) } + var showBrowser by remember { mutableStateOf(false) } + var showThreadChanges by remember { mutableStateOf(false) } + var showTodoSheet by remember { mutableStateOf(false) } + val todos = remember(messages) { TodoExtractor.latest(messages) } + val threadSummary = remember(state.threadSummaries, resolvedId) { + state.threadSummaries.firstOrNull { it.sessionId == resolvedId } + } + var minimumThreadLoadElapsed by remember(resolvedId) { mutableStateOf(false) } + var threadLoadingOverlayVisible by remember(resolvedId) { mutableStateOf(true) } + val pendingQuestion = state.pendingQuestions.firstOrNull { it.sessionID == resolvedId } + + val listState = rememberLazyListState() + LaunchedEffect(resolvedId) { + minimumThreadLoadElapsed = false + threadLoadingOverlayVisible = true + delay(1_000) + minimumThreadLoadElapsed = true + } + LaunchedEffect( + resolvedId, + isDraftSession, + isThreadLoadingMessages, + messages.isEmpty(), + minimumThreadLoadElapsed, + ) { + if (isDraftSession) { + threadLoadingOverlayVisible = false + return@LaunchedEffect + } + if (isThreadLoadingMessages || !minimumThreadLoadElapsed) { + threadLoadingOverlayVisible = true + return@LaunchedEffect + } + delay(500) + threadLoadingOverlayVisible = false + } + LaunchedEffect(messages.size) { + if (messages.isNotEmpty() && listState.firstVisibleItemIndex == 0) { + listState.animateScrollToItem(0) + } + } + LaunchedEffect(listState, resolvedId) { + snapshotFlow { listState.layoutInfo } + .filter { info -> info.visibleItemsInfo.isNotEmpty() } + .filter { info -> + val last = info.visibleItemsInfo.last() + last.index >= info.totalItemsCount - 2 && + state.sessionsWithMoreMessages.contains(resolvedId) && + !state.loadingMoreSessions.contains(resolvedId) + } + .distinctUntilChanged() + .collect { viewModel.loadMoreMessages() } + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + val titleText = session?.title?.ifBlank { "Thread" } ?: "Thread" + val hasTodos = !todos.isNullOrEmpty() + val hasContent = hasTodos || (threadSummary?.summary?.isNotEmpty() == true) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = if (hasContent) { + Modifier.clickable { showTodoSheet = true } + } else { + Modifier + }, + ) { + Text( + titleText, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + ) + if (hasTodos) { + val done = todos!!.count { it.status == TodoItem.Status.COMPLETED } + val inProgress = todos.any { it.status == TodoItem.Status.IN_PROGRESS } + TodoProgressBadge( + done = done, + total = todos.size, + inProgress = inProgress, + ) + } else if (hasContent) { + Icon( + Icons.Outlined.ExpandMore, + contentDescription = "Show summary", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.outline, + ) + } + } + }, + navigationIcon = { + if (isDetailExpanded != null && onToggleDetailExpanded != null) { + IconButton(onClick = onToggleDetailExpanded) { + Icon( + Icons.AutoMirrored.Outlined.ViewSidebar, + contentDescription = if (isDetailExpanded) { + "Show content column" + } else { + "Collapse content column" + }, + ) + } + } else { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + } + }, + actions = { + val queueSize = session?.queuedMessages?.size ?: 0 + if (queueSize > 0) { + IconButton(onClick = { showQueuedSheet = true }) { + androidx.compose.material3.BadgedBox( + badge = { androidx.compose.material3.Badge { Text(queueSize.toString()) } }, + ) { + Icon(Icons.Outlined.QueuePlayNext, contentDescription = "Queued messages") + } + } + } + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = "Thread actions") + } + androidx.compose.material3.DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + androidx.compose.material3.DropdownMenuItem( + text = { Text("View Changes") }, + onClick = { + menuExpanded = false + showThreadChanges = true + }, + leadingIcon = { Icon(Icons.Outlined.Difference, contentDescription = null) }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Open in Browser") }, + onClick = { + menuExpanded = false + showBrowser = true + }, + leadingIcon = { Icon(Icons.Outlined.Language, contentDescription = null) }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Run Profiles") }, + enabled = session?.projectId != null, + onClick = { + menuExpanded = false + showRunProfiles = true + }, + leadingIcon = { Icon(Icons.Outlined.PlayArrow, contentDescription = null) }, + ) + } + }, + ) + }, + bottomBar = { + val haptics = rememberHaptics() + Composer( + isStreaming = isStreaming, + onSend = { text -> + haptics.play(HapticEvent.LightTap) + viewModel.sendUserMessage(text) + }, + onCancel = { + haptics.play(HapticEvent.HeavyImpact) + viewModel.cancelStream() + }, + ) + } + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item("composer-gap") { + Spacer(Modifier.height(18.dp)) + } + if (isStreaming || isThinking) { + item("indicator") { StreamingIndicator(thinking = isThinking) } + } + items(messages.asReversed(), key = { it.id.toString() }) { msg -> + MessageBubble( + msg = msg, + sessionId = resolvedId, + pendingQuestions = state.pendingQuestions, + onToolCallClick = { openToolCall = it }, + onQuestionClick = { openQuestion = it }, + onEditDetailsClick = { openEditPreview = it }, + ) + } + if (state.loadingMoreSessions.contains(resolvedId)) { + item("loading") { + Box(Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } + + // Scroll-to-bottom FAB. With `reverseLayout = true` the newest + // message lives at logical-top of the list (index 0), so we show + // the FAB whenever the user has scrolled away from that index. + val scope = rememberCoroutineScope() + val showJumpToLatest by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 1 || + listState.firstVisibleItemScrollOffset > 80 + } + } + androidx.compose.animation.AnimatedVisibility( + visible = showJumpToLatest, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 12.dp), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.scaleIn(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut(), + ) { + androidx.compose.material3.SmallFloatingActionButton( + onClick = { + scope.launch { listState.animateScrollToItem(0) } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + androidx.compose.material.icons.Icons.Outlined.KeyboardArrowDown, + contentDescription = "Jump to latest", + ) + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = !isDraftSession && messages.isEmpty() && threadLoadingOverlayVisible, + enter = fadeIn(animationSpec = tween(180)) + scaleIn( + initialScale = 0.98f, + animationSpec = tween(180), + ), + exit = fadeOut(animationSpec = tween(220)) + slideOutVertically( + targetOffsetY = { it / 8 }, + animationSpec = tween(220), + ), + ) { + ThreadLoadingOverlay() + } + } + } + + state.pendingPermission?.let { req -> + PermissionApprovalSheet( + request = req, + onAllow = { viewModel.respondToPermission(allow = true) }, + onDeny = { viewModel.respondToPermission(allow = false, denyReason = "Denied") }, + onDismiss = { viewModel.respondToPermission(allow = false, denyReason = "Dismissed") }, + ) + } + (openQuestion ?: pendingQuestion)?.let { q -> + QuestionSheet( + pending = q, + onDismiss = { + openQuestion = null + viewModel.answerQuestion(q.toolUseID, emptyList()) + }, + onSubmit = { answers -> + openQuestion = null + viewModel.answerQuestion(q.toolUseID, answers) + }, + ) + } + openToolCall?.let { tc -> + ToolCallSheet(toolCall = tc, onDismiss = { openToolCall = null }) + } + if (showQueuedSheet) { + QueuedMessagesSheet( + queued = session?.queuedMessages.orEmpty(), + onDismiss = { showQueuedSheet = false }, + ) + } + openEditPreview?.let { preview -> + FileDiffSheet( + title = preview.title, + subtitle = preview.subtitle, + diffs = preview.diffs, + onDismiss = { openEditPreview = null }, + ) + } + + val projectId = session?.projectId + if (showRunProfiles && projectId != null) { + RunProfilesSheet( + profiles = state.runProfilesFor(projectId), + tasks = state.runTasksFor(projectId), + onRun = { viewModel.runRunProfile(projectId, it.id) }, + onStop = { task -> viewModel.stopRunTask(taskId = task.taskId, projectId = projectId) }, + onEdit = { editingProfile = it }, + onDelete = { viewModel.deleteRunProfile(projectId, it.id) }, + onCreate = { editingProfile = newBashRunProfile(projectId) }, + onDismiss = { showRunProfiles = false }, + ) + } + editingProfile?.let { profile -> + RunProfileEditorSheet( + initial = profile, + onSave = { updated -> + viewModel.upsertRunProfile(updated) + editingProfile = null + }, + onDismiss = { editingProfile = null }, + ) + } + + if (showTodoSheet) { + ThreadTodoSheet( + threadTitle = session?.title?.ifBlank { "Thread" } ?: "Thread", + todos = todos.orEmpty(), + summary = threadSummary, + onDismiss = { showTodoSheet = false }, + ) + } + + if (showThreadChanges) { + ThreadChangesSheet( + state = state, + viewModel = viewModel, + sessionId = resolvedId, + onDismiss = { showThreadChanges = false }, + ) + } + + if (showBrowser) { + val launchUrl = remember(messages, projectId, state.runTasks) { + val texts = messages.flatMap { msg -> msg.blocks.mapNotNull { it.text } } + val taskTexts = projectId?.let { id -> + state.runTasksFor(id).flatMap { task -> + listOfNotNull(task.commandPreview.takeIf { it.isNotBlank() }, task.terminalOutputTail) + } + }.orEmpty() + BrowserUrlDetector.detect(texts + taskTexts) + } + androidx.compose.ui.window.Dialog( + onDismissRequest = { showBrowser = false }, + properties = androidx.compose.ui.window.DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true, + dismissOnClickOutside = false, + ), + ) { + InAppBrowserScreen( + initialUrl = launchUrl, + proxyInfo = state.desktopWebProxy, + onDismiss = { showBrowser = false }, + ) + } + } +} + +/** + * One message row. User messages render as a colored bubble aligned right + * (mirrors iOS); assistant messages render as plain text aligned left, with + * no surrounding container — matching iOS where Claude's reply flows as + * inline content rather than a chat bubble. + */ +@Composable +private fun MessageBubble( + msg: ChatMessage, + sessionId: String, + pendingQuestions: List, + onToolCallClick: (ToolCall) -> Unit, + onQuestionClick: (PendingQuestionPayload) -> Unit, + onEditDetailsClick: (EditPreviewData) -> Unit, +) { + val isUser = msg.role == Role.USER + if (isUser) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(18.dp), + modifier = Modifier.widthIn(max = 320.dp), + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + if (msg.textContent.isNotEmpty()) { + // User-typed messages are plain text — no need to + // render markdown for what the user just typed. + Text(msg.textContent, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } else { + Column( + Modifier + .fillMaxWidth() + .padding(end = 24.dp, start = 4.dp, top = 2.dp, bottom = 2.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + assistantRenderBlocks(msg).forEach { block -> + when (block) { + is AssistantRenderBlock.Text -> { + RxMarkdownText( + markdown = block.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + is AssistantRenderBlock.Tool -> { + val toolCall = block.toolCall + when { + toolCall.isAskUserQuestion() -> { + QuestionToolCard( + toolCall = toolCall, + onClick = { + val pending = toolCall.pendingQuestionPayload(sessionId, pendingQuestions) + if (pending != null) { + onQuestionClick(pending) + } else { + onToolCallClick(toolCall) + } + }, + ) + } + toolCall.editPreviewData() != null -> { + EditToolCard( + toolCall = toolCall, + onShowDetails = { preview -> onEditDetailsClick(preview) }, + ) + } + else -> { + ToolCallChip(toolCall = toolCall, onClick = { onToolCallClick(toolCall) }) + } + } + } + is AssistantRenderBlock.TransientTools -> { + TransientToolGroup( + groupId = block.id, + tools = block.tools, + onToolCallClick = onToolCallClick, + ) + } + } + } + } + } +} + +private sealed interface AssistantRenderBlock { + val id: String + + data class Text(override val id: String, val text: String) : AssistantRenderBlock + data class Tool(val toolCall: ToolCall) : AssistantRenderBlock { + override val id: String = "tool-${toolCall.id}" + } + data class TransientTools(override val id: String, val tools: List) : AssistantRenderBlock +} + +private fun assistantRenderBlocks(message: ChatMessage): List { + val result = mutableListOf() + val pendingTransientTools = mutableListOf() + var pendingTransientGroupStartId: String? = null + + fun flushTransientTools() { + if (pendingTransientTools.isEmpty()) return + val startId = pendingTransientGroupStartId ?: pendingTransientTools.first().id + result += AssistantRenderBlock.TransientTools( + id = "transient-tools-$startId-${pendingTransientTools.size}", + tools = pendingTransientTools.toList(), + ) + pendingTransientTools.clear() + pendingTransientGroupStartId = null + } + + fun appendText(block: MessageBlock) { + val text = block.text?.takeIf { it.isNotEmpty() } ?: return + val lastIndex = result.lastIndex + if (lastIndex >= 0 && result[lastIndex] is AssistantRenderBlock.Text) { + val previous = result[lastIndex] as AssistantRenderBlock.Text + val needsSpace = previous.text.lastOrNull()?.isWhitespace() != true && + text.firstOrNull()?.isWhitespace() != true + result[lastIndex] = previous.copy(text = previous.text + if (needsSpace) " $text" else text) + } else { + result += AssistantRenderBlock.Text(id = block.id, text = text) + } + } + + message.blocks.forEach { block -> + val text = block.text + if (!text.isNullOrEmpty()) { + flushTransientTools() + appendText(block) + return@forEach + } + + val toolCall = block.toolCall ?: return@forEach + if (message.isStreaming) { + flushTransientTools() + result += AssistantRenderBlock.Tool(toolCall) + return@forEach + } + + if (toolCall.isTransientTool()) { + if (toolCall.result != null || toolCall.isError) { + if (pendingTransientGroupStartId == null) { + pendingTransientGroupStartId = toolCall.id + } + pendingTransientTools += toolCall + } + return@forEach + } + + if (toolCall.result != null || toolCall.isError || toolCall.isKeepAlways()) { + flushTransientTools() + result += AssistantRenderBlock.Tool(toolCall) + } + } + + flushTransientTools() + return result +} + +@Composable +private fun ToolCallChip(toolCall: ToolCall, onClick: () -> Unit) { + AssistChip( + onClick = onClick, + label = { Text(toolCall.displayName()) }, + leadingIcon = { + Icon( + toolCall.icon(), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) +} + +@Composable +private fun TransientToolGroup( + groupId: String, + tools: List, + onToolCallClick: (ToolCall) -> Unit, +) { + var expanded by remember(groupId) { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Outlined.Build, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${tools.size} ${if (tools.size == 1) "tool" else "tools"} executed", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Outlined.ExpandMore, + contentDescription = if (expanded) "Collapse tools" else "Expand tools", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (expanded) { + Column( + modifier = Modifier.padding(start = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + tools.forEach { toolCall -> + ToolCallChip(toolCall = toolCall, onClick = { onToolCallClick(toolCall) }) + } + } + } + } +} + +@Composable +private fun QuestionToolCard(toolCall: ToolCall, onClick: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.QuestionAnswer, contentDescription = null, modifier = Modifier.size(20.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Question", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + Text( + toolCall.questionSummary(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.78f), + maxLines = 2, + ) + } + } + } +} + +@Composable +private fun EditToolCard(toolCall: ToolCall, onShowDetails: (EditPreviewData) -> Unit) { + val preview = toolCall.editPreviewData() ?: return + var expanded by remember(toolCall.id) { mutableStateOf(false) } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (preview.diffs.isEmpty()) { + onShowDetails(preview) + } else { + expanded = !expanded + } + } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + if (toolCall.isError) Icons.Outlined.ErrorOutline else Icons.Outlined.Edit, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (toolCall.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + preview.title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + preview.subtitle?.let { subtitle -> + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Icon( + Icons.Outlined.ExpandMore, + contentDescription = if (expanded) "Collapse edit" else "Expand edit", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (expanded) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + preview.diffs.forEach { diff -> + FileDiffPreview(diff) + } + TextButton( + onClick = { onShowDetails(preview) }, + modifier = Modifier.align(Alignment.End), + ) { + Text("Details") + } + } + } + } + } +} + +@Composable +private fun FileDiffPreview(diff: FileDiffData) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (diff.path.isNotBlank()) { + Text( + diff.path.substringAfterLast('/'), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val scrollState = rememberScrollState() + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .horizontalScroll(scrollState) + .padding(vertical = 6.dp), + ) { + diff.inlineVisibleLines().forEach { line -> + DiffLine(line) + } + if (diff.isTruncatedAtInline) { + Text( + "…", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 2.dp), + ) + } + } + } + } +} + +@Composable +private fun DiffLine(line: String) { + val isAddition = line.startsWith("+") && !line.startsWith("+++") + val isRemoval = line.startsWith("-") && !line.startsWith("---") + val isHunk = line.startsWith("@@") + val background = when { + isAddition -> Color(0xFF2E7D32).copy(alpha = 0.12f) + isRemoval -> Color(0xFFC62828).copy(alpha = 0.12f) + isHunk -> MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + else -> Color.Transparent + } + val foreground = when { + isAddition -> Color(0xFF2E7D32) + isRemoval -> Color(0xFFC62828) + isHunk -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + } + Text( + line.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = foreground, + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(horizontal = 10.dp, vertical = 1.dp), + ) +} + +private fun ToolCall.isAskUserQuestion(): Boolean = + name.equals("AskUserQuestion", ignoreCase = true) + +private fun ToolCall.pendingQuestionPayload( + sessionId: String, + pendingQuestions: List, +): PendingQuestionPayload? { + if (result != null) return null + return pendingQuestions.firstOrNull { it.toolUseID == id } + ?: PendingQuestionPayload( + toolUseID = id, + sessionID = sessionId, + toolInputJSON = compactJson.encodeToString(JsonObject.serializer(), input), + ) +} + +private fun ToolCall.questionSummary(): String { + val rawQuestions = input.arrayValue("questions") + val first = (rawQuestions.firstOrNull() as? JsonObject)?.stringValue("question") + return first?.takeIf { it.isNotBlank() } ?: "Answer requested by the agent" +} + +private fun ToolCall.isTransientTool(): Boolean { + return when (name.normalizedToolName()) { + "read", "glob", "grep", "list", "search", "bash", "execute" -> true + else -> false + } +} + +private fun ToolCall.isKeepAlways(): Boolean = + isAskUserQuestion() || isFileModificationTool() + +private fun ToolCall.displayName(): String = displayToolName() + +private fun ToolCall.icon(): ImageVector { + return when { + isError -> Icons.Outlined.ErrorOutline + isFileModificationTool() -> Icons.Outlined.Edit + isAskUserQuestion() -> Icons.Outlined.QuestionAnswer + else -> Icons.Outlined.Build + } +} + +@Composable +private fun StreamingIndicator(thinking: Boolean) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.padding(start = 4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + TypingDots() + Text( + if (thinking) "Thinking…" else "Streaming…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ThreadLoadingOverlay() { + val transition = rememberInfiniteTransition(label = "thread-loading") + val pulse by transition.animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "thread-loading-pulse", + ) + val skeletonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f + (pulse * 0.04f)) + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.5.dp, + ) + Text( + "Loading thread", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(Modifier.height(34.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ThreadLoadingBubble( + widthFraction = 0.86f, + alignEnd = false, + rows = listOf(0.62f, 0.94f, 0.72f), + color = skeletonColor, + ) + ThreadLoadingBubble( + widthFraction = 0.64f, + alignEnd = true, + rows = listOf(0.88f, 0.52f), + color = skeletonColor, + ) + ThreadLoadingBubble( + widthFraction = 0.9f, + alignEnd = false, + rows = listOf(0.46f, 0.98f, 0.82f, 0.58f), + color = skeletonColor, + ) + } + + Spacer(Modifier.weight(1f)) + + ThreadLoadingComposer(color = skeletonColor) + } + } +} + +@Composable +private fun ThreadLoadingBubble( + widthFraction: Float, + alignEnd: Boolean, + rows: List, + color: Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (alignEnd) Arrangement.End else Arrangement.Start, + ) { + Surface( + modifier = Modifier.fillMaxWidth(widthFraction), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.58f), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + rows.forEach { rowWidth -> + Box( + Modifier + .fillMaxWidth(rowWidth) + .height(10.dp) + .background(color, RoundedCornerShape(5.dp)), + ) + } + } + } + } +} + +@Composable +private fun ThreadLoadingComposer(color: Color) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 420.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .weight(1f) + .height(14.dp) + .background(color, RoundedCornerShape(7.dp)), + ) + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(17.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + ) + } + } + } + } +} + +/** + * Three bouncing dots used inside [StreamingIndicator]. Each dot animates its + * alpha and vertical offset with an `InfiniteTransition` staggered by 150ms, + * giving the familiar iMessage-style "agent is typing" look that matches the + * iOS streaming indicator more closely than a spinning circle. + */ +@Composable +private fun TypingDots() { + val transition = androidx.compose.animation.core.rememberInfiniteTransition(label = "dots") + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) { + listOf(0, 150, 300).forEach { delayMs -> + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = androidx.compose.animation.core.infiniteRepeatable( + animation = androidx.compose.animation.core.tween( + durationMillis = 600, + delayMillis = delayMs, + easing = androidx.compose.animation.core.FastOutSlowInEasing, + ), + repeatMode = androidx.compose.animation.core.RepeatMode.Reverse, + ), + label = "dot-$delayMs", + ) + Box( + Modifier + .size(6.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = alpha), + androidx.compose.foundation.shape.CircleShape, + ) + ) + } + } +} + +@Composable +private fun Composer(isStreaming: Boolean, onSend: (String) -> Unit, onCancel: () -> Unit) { + var text by remember { mutableStateOf("") } + Surface( + tonalElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp), + maxLines = 6, + placeholder = { Text("Message…") }, + shape = RoundedCornerShape(24.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Spacer(Modifier.width(8.dp)) + if (isStreaming) { + FilledIconButton( + onClick = onCancel, + modifier = Modifier.size(44.dp), + ) { + Icon( + Icons.Outlined.Close, + contentDescription = "Stop", + modifier = Modifier.size(20.dp), + ) + } + } else { + FilledIconButton( + onClick = { + val trimmed = text.trim() + if (trimmed.isNotEmpty()) { + onSend(trimmed) + text = "" + } + }, + enabled = text.isNotBlank(), + modifier = Modifier.size(44.dp), + ) { + Icon( + Icons.AutoMirrored.Outlined.Send, + contentDescription = "Send", + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/EditPreview.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/EditPreview.kt new file mode 100644 index 00000000..5e0c73f5 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/EditPreview.kt @@ -0,0 +1,159 @@ +package app.rxlab.rxcode.ui.chat + +import app.rxlab.rxcode.proto.ToolCall +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * Compact summary of a file-mutating tool call (edit / write / multiedit) used + * by both the inline `EditToolCard` in the chat list and the full `FileDiffSheet` + * presented when the user drills into an edit. Lives outside `ChatScreen.kt` + * so the sheet can reuse the same extraction logic. + */ +data class EditPreviewData( + val title: String, + val subtitle: String?, + val diffs: List, +) + +data class FileDiffData( + val path: String, + val lines: List, +) { + val isTruncatedAtInline: Boolean get() = lines.size > 80 + fun inlineVisibleLines(): List = if (isTruncatedAtInline) lines.take(80) else lines + + val additionCount: Int get() = lines.count { it.startsWith("+") && !it.startsWith("+++") } + val removalCount: Int get() = lines.count { it.startsWith("-") && !it.startsWith("---") } +} + +fun ToolCall.editPreviewData(): EditPreviewData? { + val changes = fileChangeDiffs() + if (changes.isNotEmpty()) { + val title = if (changes.size == 1) { + "Edited ${changes.first().path.substringAfterLast('/')}" + } else { + "Edited ${changes.size} files" + } + val subtitle = changes.joinToString(", ") { it.path.substringAfterLast('/') } + .takeIf { it.isNotBlank() } + return EditPreviewData(title = title, subtitle = subtitle, diffs = changes) + } + + val normalized = name.normalizedToolName() + val filePath = input.stringValue("file_path") ?: input.stringValue("path") + val fileName = filePath?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + + if (normalized == "write") { + val content = input.stringValue("content") ?: return null + return EditPreviewData( + title = "Created ${fileName ?: "file"}", + subtitle = filePath, + diffs = listOf(FileDiffData(filePath.orEmpty(), additionsOnlyLines(content))), + ) + } + + if (normalized == "edit") { + val oldString = input.stringValue("old_string") + val newString = input.stringValue("new_string") + if (oldString != null || newString != null) { + return EditPreviewData( + title = "Edited ${fileName ?: "file"}", + subtitle = filePath, + diffs = listOf( + FileDiffData( + filePath.orEmpty(), + replacementLines(oldString.orEmpty(), newString.orEmpty()), + ) + ), + ) + } + } + + if (normalized == "multiedit" || normalized == "multi_edit") { + val edits = input.arrayValue("edits") + .mapNotNull { it as? JsonObject } + .mapNotNull { edit -> + val oldString = edit.stringValue("old_string") + val newString = edit.stringValue("new_string") + if (oldString == null && newString == null) null + else replacementLines(oldString.orEmpty(), newString.orEmpty()) + } + if (edits.isNotEmpty()) { + return EditPreviewData( + title = "Edited ${fileName ?: "file"}", + subtitle = filePath, + diffs = listOf(FileDiffData(filePath.orEmpty(), edits.flatten())), + ) + } + } + + return if (isFileModificationTool()) { + EditPreviewData(title = displayToolName(), subtitle = filePath, diffs = emptyList()) + } else { + null + } +} + +private fun ToolCall.fileChangeDiffs(): List { + val directDiff = input.stringValue("diff") + if (!directDiff.isNullOrBlank()) { + val path = input.stringValue("path") ?: input.stringValue("file_path") ?: "" + return listOf(FileDiffData(path, normalizedDiffLines(directDiff))) + } + + return input.arrayValue("changes") + .mapNotNull { it as? JsonObject } + .mapNotNull { change -> + val diff = change.stringValue("diff") ?: return@mapNotNull null + val path = change.stringValue("path") ?: change.stringValue("file_path") ?: "" + FileDiffData(path, normalizedDiffLines(diff)) + } +} + +fun ToolCall.isFileModificationTool(): Boolean { + return when (name.normalizedToolName()) { + "edit", "write", "multiedit", "multi_edit" -> true + else -> input.stringValue("type").equals("fileChange", ignoreCase = true) || + fileChangeDiffs().isNotEmpty() + } +} + +fun ToolCall.displayToolName(): String { + return when (name.normalizedToolName()) { + "bash", "execute" -> "Run command" + "read" -> "Read file" + "grep", "search" -> "Search" + "glob" -> "Find files" + "edit" -> "Edit file" + "write" -> "Write file" + "multiedit", "multi_edit" -> "Edit file" + else -> name + } +} + +internal fun JsonObject.stringValue(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull + +internal fun JsonObject.arrayValue(key: String): List = + (this[key] as? JsonArray)?.toList().orEmpty() + +internal fun String.normalizedToolName(): String = lowercase().replace("-", "_") + +internal fun normalizedDiffLines(diff: String): List { + val rawLines = diff.split('\n') + val hasAnyMarker = rawLines.any { + it.startsWith("+") || it.startsWith("-") || it.startsWith("@@") + } + return if (hasAnyMarker) rawLines else additionsOnlyLines(diff) +} + +internal fun additionsOnlyLines(content: String): List = + content.split('\n').map { if (it.isEmpty()) it else "+$it" } + +internal fun replacementLines(oldString: String, newString: String): List = + oldString.split('\n').map { if (it.isEmpty()) it else "-$it" } + + newString.split('\n').map { if (it.isEmpty()) it else "+$it" } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..fd8c77b2 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,743 @@ +package app.rxlab.rxcode.ui.onboarding + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Devices +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.LifecycleOwner +import app.rxlab.rxcode.pairing.PairingToken +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.state.PairingStatus +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.Executors + +/** Pair-with-Mac screen: device name, camera scanner, and photo QR fallback. */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun OnboardingScreen(state: MobileState, viewModel: MobileAppState) { + val context = LocalContext.current + var scannerOpen by remember { mutableStateOf(false) } + var scanOptionsOpen by remember { mutableStateOf(false) } + var localError by remember { mutableStateOf(null) } + var displayName by remember { + mutableStateOf(Build.MODEL.ifBlank { "Android" }) + } + val effectiveDisplayName = displayName.trim().ifBlank { Build.MODEL.ifBlank { "Android" } } + val photoPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) { + pairingLog("photo QR selected: $uri") + decodeQrFromImageUri( + context = context, + uri = uri, + onRawValue = { raw -> + pairingLog("photo QR decoded (${raw.length} chars)") + val token = PairingToken.parseOrNull(raw) + if (token == null) { + Log.w(TAG, "photo QR rejected: not an RxCode pairing token") + localError = "Unrecognized QR. Try scanning the one from the Mac app." + } else { + pairingLog("photo QR accepted: ${token.logSummary()}") + localError = null + viewModel.beginPairing(token, effectiveDisplayName) + } + }, + onError = { message -> localError = message }, + ) + } else { + pairingLog("photo QR picker returned no image") + } + } + + LaunchedEffect(Unit) { + pairingLog("OnboardingScreen visible; paired=${state.isPaired}") + } + + LaunchedEffect(state.isPaired) { + if (state.isPaired) { + pairingLog("pairing completed; closing scanner") + scannerOpen = false + } + } + + val haptics = rememberHaptics() + if (scannerOpen) { + PairingCameraScreen( + pairing = state.pairing, + onDismiss = { + viewModel.clearPairingError() + scannerOpen = false + }, + onToken = { token -> + haptics.play(HapticEvent.LightTap) + scannerOpen = false + localError = null + viewModel.beginPairing(token, effectiveDisplayName) + }, + onRetry = viewModel::clearPairingError, + ) + return + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(16.dp)) + Surface( + modifier = Modifier.size(112.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + tonalElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Devices, contentDescription = null, modifier = Modifier.size(48.dp)) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Pair with your Mac", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Securely link this device to RxCode in seconds.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + PairingInstructions() + + DeviceNameField( + value = displayName, + onValueChange = { displayName = it }, + onClear = { displayName = "" }, + ) + + PairingStatusCard(state.pairing, onDismiss = viewModel::clearPairingError) + localError?.let { + ErrorBanner(message = it, onDismiss = { localError = null }) + } + + Button( + onClick = { scanOptionsOpen = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Scan QR Code") + } + } + } + + if (scanOptionsOpen) { + ScanOptionsSheet( + onDismiss = { scanOptionsOpen = false }, + onCamera = { + pairingLog("opening camera QR scanner") + scanOptionsOpen = false + scannerOpen = true + }, + onPhotos = { + pairingLog("opening photo QR picker") + scanOptionsOpen = false + photoPicker.launch("image/*") + }, + ) + } +} + +@Composable +private fun PairingInstructions() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + StepRow(number = 1, text = "Open RxCode on your Mac.") + StepRow(number = 2, text = "Go to Settings > Mobile and tap Pair new device.") + StepRow(number = 3, text = "Scan the QR code shown on your Mac with this app.") + } + } +} + +@Composable +private fun StepRow(number: Int, text: String) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text(number.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + } + } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun DeviceNameField( + value: String, + onValueChange: (String) -> Unit, + onClear: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Device name", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { + Icon(Icons.Outlined.Devices, contentDescription = null) + }, + trailingIcon = { + if (value.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(Icons.Outlined.Close, contentDescription = "Clear device name") + } + } + }, + placeholder = { Text("Device name") }, + ) + } +} + +@Composable +private fun ErrorBanner(message: String, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Outlined.Close, contentDescription = "Dismiss") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScanOptionsSheet( + onDismiss: () -> Unit, + onCamera: () -> Unit, + onPhotos: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Add a QR code", style = MaterialTheme.typography.titleLarge) + Text( + "Scan the QR code shown on your Mac, or pick a screenshot from your library.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onCamera, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Outlined.CameraAlt, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Scan with Camera") + } + FilledTonalButton(onClick = onPhotos, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Outlined.Image, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Choose from Photos") + } + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("Cancel") + } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun PairingStatusCard(pairing: PairingStatus, onDismiss: () -> Unit) { + when (pairing) { + PairingStatus.Idle -> Unit + PairingStatus.InProgress -> Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Pairing in progress", style = MaterialTheme.typography.titleMedium) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + is PairingStatus.Failed -> Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Pairing failed", style = MaterialTheme.typography.titleMedium) + Text(pairing.message, style = MaterialTheme.typography.bodyMedium) + OutlinedButton(onClick = onDismiss) { Text("Dismiss") } + } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun PairingCameraScreen( + pairing: PairingStatus, + onDismiss: () -> Unit, + onToken: (PairingToken) -> Unit, + onRetry: () -> Unit, +) { + val camPermission = rememberPermissionState(android.Manifest.permission.CAMERA) + var scanLocked by remember { mutableStateOf(false) } + var scannerKey by remember { mutableStateOf(0) } + var scanError by remember { mutableStateOf(null) } + + LaunchedEffect(camPermission.status.isGranted) { + pairingLog("camera permission granted=${camPermission.status.isGranted}") + } + + Scaffold( + containerColor = Color.Black, + topBar = { + CenterAlignedTopAppBar( + title = { Text("Scan pairing QR") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(Color.Black), + ) { + if (camPermission.status.isGranted) { + QRCameraPreview( + modifier = Modifier.fillMaxSize(), + scanKey = scannerKey, + enabled = !scanLocked && pairing !is PairingStatus.InProgress, + onToken = { token -> + pairingLog("camera QR accepted: ${token.logSummary()}") + scanLocked = true + scanError = null + onToken(token) + }, + onInvalidQr = { + if (!scanLocked) { + Log.w(TAG, "camera QR rejected: not an RxCode pairing token") + scanError = "That QR code is not an RxCode pairing code." + } + }, + ) + CameraShade() + Box( + modifier = Modifier + .align(Alignment.Center) + .size(252.dp) + .border(3.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(28.dp)), + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(16.dp)) + Text("Camera access is required to scan the pairing QR.") + Spacer(Modifier.height(16.dp)) + Button(onClick = { camPermission.launchPermissionRequest() }) { + Text("Allow camera access") + } + } + } + + PairingCameraStatus( + pairing = pairing, + scanError = scanError, + onRetry = { + pairingLog("retrying camera QR scan") + onRetry() + scanError = null + scanLocked = false + scannerKey += 1 + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + ) + } + } +} + +@Composable +private fun CameraShade() { + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.58f), + 0.36f to Color.Transparent, + 0.64f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.72f), + ) + ) + ) +} + +@Composable +private fun PairingCameraStatus( + pairing: PairingStatus, + scanError: String?, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + tonalElevation = 6.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + when (pairing) { + PairingStatus.Idle -> { + Text("Align the QR code inside the frame.", style = MaterialTheme.typography.titleMedium) + scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + PairingStatus.InProgress -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) + Text("QR detected. Waiting for your Mac…", style = MaterialTheme.typography.titleMedium) + } + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + is PairingStatus.Failed -> { + Text("Pairing failed", style = MaterialTheme.typography.titleMedium) + Text(pairing.message, style = MaterialTheme.typography.bodyMedium) + FilledTonalButton(onClick = onRetry, modifier = Modifier.fillMaxWidth()) { + Text("Scan again") + } + } + } + } + } +} + +@Composable +private fun QRCameraPreview( + modifier: Modifier = Modifier, + scanKey: Int, + enabled: Boolean, + onToken: (PairingToken) -> Unit, + onInvalidQr: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val enabledState = rememberUpdatedState(enabled) + val previewView = remember(context) { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + + AndroidView( + modifier = modifier, + factory = { previewView }, + ) + + DisposableEffect(context, lifecycleOwner, previewView, scanKey) { + val cameraBinding = bindCamera( + context = context, + owner = lifecycleOwner, + previewView = previewView, + enabled = { enabledState.value }, + onToken = onToken, + onInvalidQr = onInvalidQr, + ) + onDispose { cameraBinding.dispose() } + } +} + +private class CameraBinding( + private val disposeAction: () -> Unit, +) { + fun dispose() = disposeAction() +} + +private fun bindCamera( + context: Context, + owner: LifecycleOwner, + previewView: PreviewView, + enabled: () -> Boolean, + onToken: (PairingToken) -> Unit, + onInvalidQr: () -> Unit, +): CameraBinding { + pairingLog("binding camera scanner") + val providerFuture = ProcessCameraProvider.getInstance(context) + val mainExecutor = androidx.core.content.ContextCompat.getMainExecutor(context) + val executor = Executors.newSingleThreadExecutor() + var provider: ProcessCameraProvider? = null + var scanner: BarcodeScanner? = null + var disposed = false + providerFuture.addListener({ + if (disposed) return@addListener + provider = runCatching { providerFuture.get() } + .onFailure { Log.e(TAG, "camera provider failed: ${it.message}", it) } + .getOrNull() + ?: return@addListener + val preview = Preview.Builder().build().apply { + setSurfaceProvider(previewView.surfaceProvider) + } + scanner = BarcodeScanning.getClient() + pairingLog("ML Kit barcode scanner created") + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + var lastSeen: String? = null + var reportedInvalid = false + var analyzedFrames = 0 + var disabledFrames = 0 + analysis.setAnalyzer(executor) { proxy: ImageProxy -> + if (!enabled()) { + disabledFrames += 1 + if (disabledFrames == 1) { + pairingLog("camera analyzer paused while pairing is in progress or scan is locked") + } + proxy.close() + return@setAnalyzer + } + disabledFrames = 0 + analyzedFrames += 1 + val mediaImage = proxy.image + if (mediaImage == null) { + if (analyzedFrames == 1 || analyzedFrames % 90 == 0) { + Log.w(TAG, "camera frame has no image (frames=$analyzedFrames)") + } + proxy.close() + return@setAnalyzer + } + val input = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees) + val activeScanner = scanner + if (activeScanner == null) { + Log.w(TAG, "camera analyzer has no active barcode scanner") + proxy.close() + return@setAnalyzer + } + activeScanner.process(input) + .addOnSuccessListener(mainExecutor) { barcodes -> + val qrValues = barcodes + .filter { it.format == Barcode.FORMAT_QR_CODE || it.rawValue != null } + .mapNotNull { it.rawValue } + if (qrValues.isEmpty()) { + if (analyzedFrames % 90 == 0) { + pairingLog("camera scanner active; no QR found yet (frames=$analyzedFrames)") + } + } else { + pairingLog("camera scanner found ${barcodes.size} barcode(s), ${qrValues.size} raw value(s)") + } + qrValues.firstOrNull { it != lastSeen }?.let { raw -> + lastSeen = raw + pairingLog("camera QR decoded (${raw.length} chars)") + val token = PairingToken.parseOrNull(raw) + if (token != null) { + reportedInvalid = false + onToken(token) + } else if (!reportedInvalid) { + reportedInvalid = true + onInvalidQr() + } + } + } + .addOnFailureListener(mainExecutor) { error -> + Log.w(TAG, "camera QR processing failed: ${error.message}", error) + } + .addOnCompleteListener { proxy.close() } + } + provider?.unbindAll() + runCatching { + provider?.bindToLifecycle(owner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) + }.onSuccess { + pairingLog("camera scanner bound to lifecycle") + }.onFailure { + Log.e(TAG, "camera scanner bind failed: ${it.message}", it) + } + }, mainExecutor) + + return CameraBinding { + pairingLog("disposing camera scanner") + disposed = true + provider?.unbindAll() + scanner?.close() + executor.shutdown() + } +} + +private fun decodeQrFromImageUri( + context: Context, + uri: Uri, + onRawValue: (String) -> Unit, + onError: (String) -> Unit, +) { + pairingLog("decoding QR from selected image: $uri") + val scanner = BarcodeScanning.getClient() + val input = runCatching { InputImage.fromFilePath(context, uri) } + .getOrElse { + Log.w(TAG, "selected image could not be read: ${it.message}", it) + scanner.close() + onError("Couldn't read the selected image.") + return + } + scanner.process(input) + .addOnSuccessListener { barcodes -> + val value = barcodes + .firstOrNull { it.format == Barcode.FORMAT_QR_CODE || it.rawValue != null } + ?.rawValue + if (value == null) { + Log.w(TAG, "selected image contains no QR code (barcodes=${barcodes.size})") + onError("No QR code found in that image.") + } else { + pairingLog("selected image contains QR (${value.length} chars)") + onRawValue(value) + } + } + .addOnFailureListener { + Log.w(TAG, "selected image QR scan failed: ${it.message}", it) + onError("Couldn't load the selected image.") + } + .addOnCompleteListener { + scanner.close() + } +} + +private fun PairingToken.logSummary(): String = + "desktop=${desktopName.ifBlank { "unknown" }}, relay=${relayURL}, desktopPubkey=${desktopPubkeyHex.take(12)}, expiresAt=$expiresAt, expired=$isExpired" + +private fun pairingLog(message: String) { + Log.w(TAG, message) +} + +private const val TAG = "OnboardingScreen" 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 new file mode 100644 index 00000000..8bac1ed4 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsPane.kt @@ -0,0 +1,398 @@ +package app.rxlab.rxcode.ui.projects + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ViewSidebar +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import app.rxlab.rxcode.state.resolveSessionId +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.chat.ChatScreen +import app.rxlab.rxcode.ui.sessions.SessionsScreen +import java.util.UUID +import kotlinx.coroutines.launch + +/** + * Adaptive Projects → Sessions → Chat flow. + * + * - On compact widths the [ListDetailPaneScaffold] auto-collapses to one pane, + * recreating the iPhone push-style navigation (projects → sessions → chat + * back via system back). + * - On medium / expanded widths (foldables unfolded, tablets, ChromeOS) both + * panes are visible. The left pane drills projects → sessions inside itself + * so the right pane stays on the chat — that mirrors iOS's iPad split view + * where the sidebar and content column live left of the chat detail. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ProjectsPaneScreen( + state: MobileState, + viewModel: MobileAppState, + onSettingsClick: () -> Unit, +) { + var selectedProjectId by rememberSaveable(stateSaver = uuidSaver) { + mutableStateOf(null) + } + + val paneDirective = calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth( + currentWindowAdaptiveInfo(), + ) + val shouldUseSplitLayout = paneDirective.maxHorizontalPartitions >= 2 + + BoxWithConstraints(Modifier.fillMaxSize()) { + if (shouldUseSplitLayout) { + WideProjectsPane( + state = state, + viewModel = viewModel, + selectedProjectId = selectedProjectId, + onSelectProject = { selectedProjectId = it }, + onSettingsClick = onSettingsClick, + onBackToProjects = { selectedProjectId = null }, + availableWidth = maxWidth, + ) + } else { + CollapsingProjectsPane( + state = state, + viewModel = viewModel, + selectedProjectId = selectedProjectId, + onSelectProject = { selectedProjectId = it }, + onSettingsClick = onSettingsClick, + onBackToProjects = { selectedProjectId = null }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun CollapsingProjectsPane( + state: MobileState, + viewModel: MobileAppState, + selectedProjectId: UUID?, + onSelectProject: (UUID) -> Unit, + onSettingsClick: () -> Unit, + onBackToProjects: () -> Unit, +) { + val navigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + + BackHandler(enabled = navigator.canNavigateBack() || selectedProjectId != null) { + when { + navigator.canNavigateBack() -> scope.launch { navigator.navigateBack() } + selectedProjectId != null -> onBackToProjects() + } + } + + // React to a cross-tab navigation (Briefing → thread). When activeSessionID + // changes from outside this scaffold, locate its owning project, drill the + // list pane into that project's sessions, and push the chat detail pane. + LaunchedEffect(state.activeSessionID, state.sessions) { + val sid = state.activeSessionID ?: return@LaunchedEffect + val resolved = state.resolveSessionId(sid) + val pid = state.sessions.firstOrNull { it.id == resolved || it.id == sid }?.projectId + ?: projectIdFromDraftSession(sid) + if (pid != null && selectedProjectId != pid) { + onSelectProject(pid) + } + val detailContent = navigator.currentDestination?.content + if (detailContent != sid) { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, sid) + } + } + + ListDetailPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + listPane = { + AnimatedPane { + val pid = selectedProjectId + if (pid == null) { + ProjectsScreen( + state = state, + onProjectClick = { project -> onSelectProject(project.id) }, + onSettingsClick = onSettingsClick, + viewModel = viewModel, + ) + } else { + SessionsScreen( + state = state, + projectId = pid, + onSessionClick = { session -> + viewModel.selectSession(session.id) + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, session.id) + } + }, + onNewThread = { planMode -> + viewModel.startNewSession(pid, planMode = planMode) + val draftId = state.activeSessionID + if (draftId != null) { + scope.launch { + navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, draftId) + } + } + }, + onBack = onBackToProjects, + viewModel = viewModel, + ) + } + } + }, + detailPane = { + AnimatedPane { + val sid = navigator.currentDestination?.content + if (sid != null) { + ChatScreen( + state = state, + viewModel = viewModel, + sessionId = sid, + onBack = { + viewModel.selectSession(null) + scope.launch { navigator.navigateBack() } + }, + ) + } else { + ChatPlaceholder(modifier = Modifier.fillMaxSize()) + } + } + }, + ) +} + +@Composable +private fun WideProjectsPane( + state: MobileState, + viewModel: MobileAppState, + selectedProjectId: UUID?, + onSelectProject: (UUID) -> Unit, + onSettingsClick: () -> Unit, + onBackToProjects: () -> Unit, + availableWidth: Dp, +) { + var selectedSessionId by rememberSaveable { mutableStateOf(null) } + var isDetailExpanded by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(state.activeSessionID, state.sessions) { + val sid = state.activeSessionID ?: return@LaunchedEffect + val resolved = state.resolveSessionId(sid) + val pid = state.sessions.firstOrNull { it.id == resolved || it.id == sid }?.projectId + ?: projectIdFromDraftSession(sid) + if (pid != null && selectedProjectId != pid) { + onSelectProject(pid) + } + selectedSessionId = sid + } + + BackHandler(enabled = isDetailExpanded || selectedSessionId != null || selectedProjectId != null) { + when { + isDetailExpanded -> isDetailExpanded = false + selectedSessionId != null -> { + selectedSessionId = null + viewModel.selectSession(null) + } + selectedProjectId != null -> onBackToProjects() + } + } + + val contentWidth = (availableWidth * 0.42f).coerceIn(300.dp, 460.dp) + + Row(Modifier.fillMaxSize()) { + if (!isDetailExpanded) { + Box( + modifier = Modifier + .width(contentWidth) + .fillMaxHeight(), + ) { + val pid = selectedProjectId + if (pid == null) { + ProjectsScreen( + state = state, + onProjectClick = { project -> + onSelectProject(project.id) + selectedSessionId = null + viewModel.selectSession(null) + }, + onSettingsClick = onSettingsClick, + viewModel = viewModel, + ) + } else { + SessionsScreen( + state = state, + projectId = pid, + onSessionClick = { session -> + viewModel.selectSession(session.id) + selectedSessionId = session.id + }, + onNewThread = { planMode -> + viewModel.startNewSession(pid, planMode = planMode) + state.activeSessionID?.let { selectedSessionId = it } + }, + onBack = onBackToProjects, + viewModel = viewModel, + selectedSessionId = selectedSessionId, + ) + } + } + VerticalDivider() + } + Box(Modifier.weight(1f).fillMaxHeight()) { + val sid = selectedSessionId + if (sid != null) { + ChatScreen( + state = state, + viewModel = viewModel, + sessionId = sid, + onBack = { + selectedSessionId = null + viewModel.selectSession(null) + }, + isDetailExpanded = isDetailExpanded, + onToggleDetailExpanded = { isDetailExpanded = !isDetailExpanded }, + ) + } else { + ChatPlaceholder( + modifier = Modifier.fillMaxSize(), + isDetailExpanded = isDetailExpanded, + onToggleDetailExpanded = { isDetailExpanded = !isDetailExpanded }, + ) + } + } + } +} + +@Composable +private fun ProjectContentPlaceholder(modifier: Modifier = Modifier) { + Surface(modifier = modifier, color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(56.dp), + ) + Text( + "Select a project", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Pick a project from the sidebar to see its threads.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + +@Composable +private fun ChatPlaceholder( + modifier: Modifier = Modifier, + isDetailExpanded: Boolean? = null, + onToggleDetailExpanded: (() -> Unit)? = null, +) { + Surface(modifier = modifier, color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center) { + if (isDetailExpanded != null && onToggleDetailExpanded != null) { + IconButton( + onClick = onToggleDetailExpanded, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) { + Icon( + Icons.AutoMirrored.Outlined.ViewSidebar, + contentDescription = if (isDetailExpanded) "Show content column" else "Collapse content column", + ) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(56.dp), + ) + Text( + "Select a thread", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "Pick a thread in the content column, or start a new one.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } +} + +private fun Dp.coerceIn(minimumValue: Dp, maximumValue: Dp): Dp { + return when { + this < minimumValue -> minimumValue + this > maximumValue -> maximumValue + else -> this + } +} + +private val uuidSaver: Saver = Saver( + save = { it?.toString() ?: "" }, + restore = { if (it.isEmpty()) null else UUID.fromString(it) }, +) + +/** + * `startNewSession` synthesizes a draft id of the form `draft-new::` + * before the desktop assigns a real one. Pull the project id out of it so we + * can drill into the right project's sessions list while waiting for the real + * session id to land. + */ +private fun projectIdFromDraftSession(id: String): UUID? { + if (!id.startsWith("draft-new:")) return null + val parts = id.removePrefix("draft-new:").split(":") + val raw = parts.firstOrNull() ?: return null + return runCatching { UUID.fromString(raw) }.getOrNull() +} 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 new file mode 100644 index 00000000..4e8bcbab --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/projects/ProjectsScreen.kt @@ -0,0 +1,240 @@ +package app.rxlab.rxcode.ui.projects + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.lazy.LazyColumn +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.Settings +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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.unit.dp +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import app.rxlab.rxcode.proto.Project +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ProjectsScreen( + state: MobileState, + onProjectClick: (Project) -> Unit, + onSettingsClick: () -> Unit, + selectedProjectId: java.util.UUID? = null, + viewModel: MobileAppState? = null, +) { + val haptics = rememberHaptics() + val scope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { Text("Projects") }, + actions = { + IconButton(onClick = onSettingsClick) { + Icon(Icons.Outlined.Settings, contentDescription = "Settings") + } + } + ) + } + ) { padding -> + when { + !state.hasReceivedInitialSnapshot -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator() + Text("Waiting for your Mac…", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + state.projects.isEmpty() -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + EmptyProjects() + } + + else -> PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + haptics.play(HapticEvent.LightTap) + refreshing = true + viewModel?.requestSnapshot("pull_to_refresh") + scope.launch { + delay(800) + refreshing = false + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.projects, key = { it.id }) { project -> + val isSelected = project.id == selectedProjectId + val threadCount = state.sessions.count { it.projectId == project.id && !it.isArchived } + val activeJobCount = state.sessions.count { it.projectId == project.id && it.isStreaming } + val currentBranch = state.projectBranches[project.id]?.currentBranch + ElevatedCard( + modifier = Modifier + .fillMaxWidth(), + onClick = { + haptics.play(HapticEvent.LightTap) + onProjectClick(project) + }, + colors = CardDefaults.elevatedCardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Folder, contentDescription = null) + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + project.name, + style = MaterialTheme.typography.titleMedium, + softWrap = true, + ) + Text( + project.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = true, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + if (!currentBranch.isNullOrBlank()) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(currentBranch, softWrap = true) }, + leadingIcon = { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Text( + "$threadCount threads", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) + if (activeJobCount > 0) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text("$activeJobCount active") }, + leadingIcon = { + Icon( + Icons.Outlined.Bolt, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledLeadingIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun EmptyProjects() { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Folder, contentDescription = null, modifier = Modifier.size(32.dp)) + } + } + Text("No projects yet", style = MaterialTheme.typography.titleMedium) + Text("Add a project on macOS.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} 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 new file mode 100644 index 00000000..421a1112 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -0,0 +1,435 @@ +package app.rxlab.rxcode.ui.sessions + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Delete +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.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +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 kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.SessionAttentionKind +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.sheets.NewThreadSheet +import app.rxlab.rxcode.ui.sheets.RenameThreadSheet +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import app.rxlab.rxcode.ui.util.relativeTime +import java.util.UUID + +/** + * Thread list for one project. Mirrors `SessionsList` on iOS — each row shows + * the title, a relative-time stamp, a branch chip when a branch is recorded + * on the session's owning project, plus streaming / attention chips. + * + * Long-pressing the overflow icon opens a dropdown menu for rename, archive, + * and delete; rename pops a [RenameThreadSheet]. The FAB triggers a + * [NewThreadSheet] populated with the project's current/available branches. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionsScreen( + state: MobileState, + projectId: UUID, + onSessionClick: (SessionSummary) -> Unit, + onNewThread: (planMode: Boolean) -> Unit, + onBack: () -> Unit, + viewModel: MobileAppState? = null, + selectedSessionId: String? = null, +) { + val haptics = rememberHaptics() + val project = state.projects.firstOrNull { it.id == projectId } + val sessions = remember(state.sessions, projectId) { + state.sessions.filter { it.projectId == projectId && !it.isArchived } + } + val branchInfo = state.projectBranches[projectId] + var newThreadOpen by remember { mutableStateOf(false) } + var renameTarget by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { Text(project?.name ?: "Threads") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text("New thread") }, + icon = { Icon(Icons.Outlined.Add, contentDescription = null) }, + onClick = { + haptics.play(HapticEvent.LightTap) + newThreadOpen = true + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + haptics.play(HapticEvent.LightTap) + refreshing = true + viewModel?.requestSnapshot("pull_to_refresh") + scope.launch { + delay(800) + refreshing = false + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (sessions.isEmpty()) { + EmptySessionsState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, vertical = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(sessions, key = { it.id }) { session -> + SessionCard( + session = session, + isSelected = session.id == selectedSessionId, + onClick = { + haptics.play(HapticEvent.LightTap) + onSessionClick(session) + }, + onRename = { renameTarget = session }, + onArchive = { + haptics.play(HapticEvent.HeavyImpact) + viewModel?.archiveThread(session.id) + }, + onDelete = { deleteTarget = session }, + ) + } + } + } + } + } + + if (newThreadOpen) { + NewThreadSheet( + projectId = projectId, + branchInfo = branchInfo, + onDismiss = { newThreadOpen = false }, + onSubmit = { planMode, _, _ -> + newThreadOpen = false + onNewThread(planMode) + }, + ) + } + + renameTarget?.let { target -> + RenameThreadSheet( + currentTitle = target.title, + onDismiss = { renameTarget = null }, + onSubmit = { newTitle -> + viewModel?.renameThread(target.id, newTitle) + renameTarget = null + }, + ) + } + + deleteTarget?.let { target -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { deleteTarget = null }, + icon = { Icon(Icons.Outlined.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Delete thread?") }, + text = { + Text( + "This permanently removes \"${target.title.ifBlank { "Untitled" }}\" and its messages from both your Mac and this device.", + ) + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + haptics.play(HapticEvent.HeavyImpact) + viewModel?.deleteThread(target.id) + deleteTarget = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun EmptySessionsState(modifier: Modifier = Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(24.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } + } + Text("No threads yet", style = MaterialTheme.typography.titleMedium) + Text( + "Tap New thread to start a conversation.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Thread row mirroring iOS `GlassThreadCard`. Shows a status dot, the title, + * relative updated time, optional todo progress, and a streaming indicator + * when the thread is actively running. + */ +@Composable +private fun SessionCard( + session: SessionSummary, + isSelected: Boolean, + onClick: () -> Unit, + onRename: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + colors = CardDefaults.elevatedCardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + StatusDot(session) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + session.title.ifBlank { "Untitled" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UpdatedTimeLabel(session) + session.todos?.takeIf { it.isNotEmpty() }?.let { TodoProgressLabel(it) } + } + } + if (session.isStreaming) { + StreamingDots() + } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Outlined.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Outlined.DriveFileRenameOutline, contentDescription = null) }, + onClick = { menuOpen = false; onRename() }, + ) + DropdownMenuItem( + text = { Text("Archive") }, + leadingIcon = { Icon(Icons.Outlined.Archive, contentDescription = null) }, + onClick = { menuOpen = false; onArchive() }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { menuOpen = false; onDelete() }, + ) + } + } + } + } +} + +@Composable +private fun StatusDot(session: SessionSummary) { + val color = when { + session.attention == SessionAttentionKind.QUESTION -> Color(0xFFEAB308) + session.attention == SessionAttentionKind.PERMISSION -> Color(0xFFF97316) + session.hasUncheckedCompletion && !session.isStreaming -> Color(0xFF22C55E) + else -> null + } ?: return + Surface( + shape = CircleShape, + color = color, + modifier = Modifier.size(10.dp), + ) {} +} + +@Composable +private fun UpdatedTimeLabel(session: SessionSummary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Outlined.Schedule, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + relativeTime(session.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TodoProgressLabel(todos: List) { + val completed = todos.count { it.status == TodoItem.Status.COMPLETED } + val total = todos.size + val allDone = completed == total + val inProgress = todos.any { it.status == TodoItem.Status.IN_PROGRESS } + val tint = if (allDone) Color(0xFF22C55E) else MaterialTheme.colorScheme.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + when { + inProgress -> CircularProgressIndicator( + modifier = Modifier.size(11.dp), + strokeWidth = 1.5.dp, + color = tint, + ) + allDone -> Icon( + Icons.Outlined.CheckCircle, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tint, + ) + else -> Icon( + Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = tint, + ) + } + Text( + "$completed/$total", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = tint, + ) + } +} + +@Composable +private fun StreamingDots() { + val transition = rememberInfiniteTransition(label = "streaming-dots") + val color = MaterialTheme.colorScheme.primary + Row( + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 4.dp), + ) { + repeat(3) { index -> + val alpha by transition.animateFloat( + initialValue = 0.4f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 400, delayMillis = index * 120), + repeatMode = RepeatMode.Reverse, + ), + label = "streaming-dot-$index", + ) + Surface( + shape = CircleShape, + color = color.copy(alpha = alpha), + modifier = Modifier.size(6.dp), + ) {} + } + } +} 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 new file mode 100644 index 00000000..94f49935 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/settings/SettingsScreen.kt @@ -0,0 +1,408 @@ +package app.rxlab.rxcode.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DesktopMac +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Router +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +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.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.BuildConfig +import app.rxlab.rxcode.relay.RelayClient +import app.rxlab.rxcode.state.MobileAppState +import app.rxlab.rxcode.state.MobileState +import app.rxlab.rxcode.store.PairedDesktop +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics + +/** + * Settings screen. Mirrors `MobileSettingsView` on iOS with the subsections + * we can implement against the current Android sync proto: + * + * - Paired Macs (switch active / unpair, with active checkmark) + * - Connection (relay URL + current websocket state as a colored chip) + * - About (app version) + * + * Sub-screens for MCP servers, ACP clients, skills, run profiles, and remote + * folder picker are still gated on `MobileSettingsSnapshot` flowing through + * the Android sync proto — they'll plug into this scaffold as additional + * sections once that lands. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + state: MobileState, + viewModel: MobileAppState, + onBack: () -> Unit, + onPairNewMac: (() -> Unit)? = null, +) { + val haptics = rememberHaptics() + var unpairTarget by remember { mutableStateOf(null) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + CenterAlignedTopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // MARK: - Paired Macs + item { + SectionLabel("Paired Macs") + } + items(state.pairedDesktops, key = { it.id }) { desktop -> + PairedDesktopCard( + desktop = desktop, + isActive = desktop.pubkeyHex == state.activeDesktopPubkey, + onSwitch = { + haptics.play(HapticEvent.Selection) + viewModel.switchActiveDesktop(desktop) + }, + onUnpair = { unpairTarget = desktop }, + ) + } + if (onPairNewMac != null) { + item { + OutlinedButton( + onClick = { + haptics.play(HapticEvent.LightTap) + onPairNewMac() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Pair a new Mac") + } + } + } + + // MARK: - Connection + item { + SectionLabel("Connection") + } + item { + ConnectionCard( + relayUrl = state.relayUrl, + connectionState = state.connectionState, + onReconnect = { + haptics.play(HapticEvent.LightTap) + viewModel.requestSnapshot("manual_reconnect") + }, + ) + } + + // MARK: - About + item { + SectionLabel("About") + } + item { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Info, contentDescription = null) + } + } + Column(Modifier.weight(1f)) { + Text( + "RxCode for Android", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + + // Confirm before unpairing — the iOS app shows a destructive confirm too. + unpairTarget?.let { target -> + AlertDialog( + onDismissRequest = { unpairTarget = null }, + icon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Unpair ${target.displayName}?") }, + text = { + Text( + "This device will no longer sync with this Mac. " + + "You can pair again from the Mac's RxCode Settings → Mobile menu.", + ) + }, + confirmButton = { + TextButton(onClick = { + haptics.play(HapticEvent.HeavyImpact) + viewModel.removeDesktop(target) + unpairTarget = null + }) { + Text("Unpair", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { unpairTarget = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + ) +} + +@Composable +private fun PairedDesktopCard( + desktop: PairedDesktop, + isActive: Boolean, + onSwitch: () -> Unit, + onUnpair: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + onClick = { if (!isActive) onSwitch() }, + colors = CardDefaults.elevatedCardColors( + containerColor = if (isActive) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + shape = CircleShape, + color = if (isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.secondaryContainer, + contentColor = if (isActive) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.DesktopMac, contentDescription = null) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + desktop.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + desktop.relayUrl?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + if (isActive) { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = "Active", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = onUnpair) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Unpair", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ConnectionCard( + relayUrl: String, + connectionState: RelayClient.ConnectionState, + onReconnect: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Router, contentDescription = null) + } + } + Column(Modifier.weight(1f)) { + Text( + "Relay", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + relayUrl.ifBlank { "—" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + ConnectionStateChip(connectionState) + } + if (connectionState == RelayClient.ConnectionState.DISCONNECTED) { + OutlinedButton( + onClick = onReconnect, + modifier = Modifier.fillMaxWidth(), + ) { Text("Try to reconnect") } + } + } + } +} + +@Composable +private fun ConnectionStateChip(state: RelayClient.ConnectionState) { + val (label, dotColor, container, content) = when (state) { + RelayClient.ConnectionState.CONNECTED -> Quadruple( + "Connected", + Color(0xFF34C759), + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + ) + RelayClient.ConnectionState.CONNECTING -> Quadruple( + "Connecting", + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + ) + RelayClient.ConnectionState.RECONNECTING -> Quadruple( + "Reconnecting", + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer, + ) + RelayClient.ConnectionState.DISCONNECTED -> Quadruple( + "Offline", + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.errorContainer, + MaterialTheme.colorScheme.onErrorContainer, + ) + } + AssistChip( + onClick = {}, + enabled = false, + label = { Text(label) }, + leadingIcon = { + Box( + Modifier + .size(8.dp) + .background(dotColor, CircleShape) + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = container, + disabledLabelColor = content, + disabledLeadingIconContentColor = content, + ), + ) +} + +/** + * Tiny ad-hoc holder so `ConnectionStateChip` can return four pieces of + * styling from its `when` mapping without an inline class boilerplate. + * `data class` synthesizes the `componentN` operators automatically. + */ +private data class Quadruple(val a: A, val b: B, val c: C, val d: D) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/FileDiffSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/FileDiffSheet.kt new file mode 100644 index 00000000..0f6fb394 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/FileDiffSheet.kt @@ -0,0 +1,277 @@ +package app.rxlab.rxcode.ui.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.fillMaxSize +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.WrapText +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.ui.chat.FileDiffData + +/** + * Modal sheet that shows one or more file diffs from an edit tool call — + * mirrors the iOS `ThreadChangeDetailView` UX: monospace lines, gutter line + * numbers, hunk highlighting, and a wrap/scroll toggle. Used when the user + * taps "Details" on an `EditToolCard` in the chat list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FileDiffSheet( + title: String, + subtitle: String?, + diffs: List, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var wrap by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + subtitle?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val (added, removed) = remember(diffs) { + diffs.fold(0 to 0) { acc, d -> + (acc.first + d.additionCount) to (acc.second + d.removalCount) + } + } + if (added > 0) DiffCountChip("+$added", Color(0xFF2E7D32)) + if (removed > 0) DiffCountChip("-$removed", Color(0xFFC62828)) + Spacer(Modifier.weight(1f)) + AssistChip( + onClick = { wrap = !wrap }, + label = { Text(if (wrap) "Wrap" else "Scroll") }, + leadingIcon = { + Icon( + if (wrap) Icons.AutoMirrored.Outlined.WrapText else Icons.Outlined.SwapHoriz, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + ) + } + + if (diffs.isEmpty()) { + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No diff available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(diffs, key = { it.path.ifEmpty { it.hashCode().toString() } }) { diff -> + FileDiffBlock(diff = diff, wrap = wrap) + } + item { Spacer(Modifier.height(16.dp)) } + } + } + } + } +} + +@Composable +private fun DiffCountChip(label: String, color: Color) { + Surface( + shape = RoundedCornerShape(8.dp), + color = color.copy(alpha = 0.14f), + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = color, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun FileDiffBlock(diff: FileDiffData, wrap: Boolean) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column { + if (diff.path.isNotBlank()) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + diff.path, + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + val scrollState = rememberScrollState() + val numbered = remember(diff.lines) { numberDiffLines(diff.lines) } + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (wrap) Modifier else Modifier.horizontalScroll(scrollState)) + .padding(vertical = 6.dp), + ) { + numbered.forEach { line -> + DiffLineRow(line, wrap = wrap) + } + } + } + } +} + +@Composable +private fun DiffLineRow(line: NumberedDiffLine, wrap: Boolean) { + val background = when (line.kind) { + DiffLineKind.ADDITION -> Color(0xFF2E7D32).copy(alpha = 0.12f) + DiffLineKind.REMOVAL -> Color(0xFFC62828).copy(alpha = 0.12f) + DiffLineKind.HUNK -> MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + DiffLineKind.CONTEXT -> Color.Transparent + } + val foreground = when (line.kind) { + DiffLineKind.ADDITION -> Color(0xFF2E7D32) + DiffLineKind.REMOVAL -> Color(0xFFC62828) + DiffLineKind.HUNK -> MaterialTheme.colorScheme.primary + DiffLineKind.CONTEXT -> MaterialTheme.colorScheme.onSurface + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(horizontal = 8.dp, vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + line.oldNumber?.toString().orEmpty(), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(36.dp), + ) + Text( + line.newNumber?.toString().orEmpty(), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(36.dp), + ) + Text( + line.text.ifEmpty { " " }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = foreground, + softWrap = wrap, + modifier = Modifier.padding(start = 4.dp), + ) + } +} + +private enum class DiffLineKind { ADDITION, REMOVAL, HUNK, CONTEXT } + +private data class NumberedDiffLine( + val text: String, + val kind: DiffLineKind, + val oldNumber: Int?, + val newNumber: Int?, +) + +/** + * Assigns left (original) and right (modified) gutter numbers by walking the + * unified-diff lines. Hunk headers reset both counters by parsing their + * `@@ -a,b +c,d @@` ranges; lines without markers (already-additions-only or + * pure replacement output) get sequential `newNumber`s only. + */ +private fun numberDiffLines(lines: List): List { + var oldCursor = 1 + var newCursor = 1 + val hunkRegex = Regex("""^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@""") + return lines.map { raw -> + val kind = when { + raw.startsWith("+++") || raw.startsWith("---") -> DiffLineKind.HUNK + raw.startsWith("@@") -> { + hunkRegex.find(raw)?.let { match -> + oldCursor = match.groupValues[1].toIntOrNull() ?: oldCursor + newCursor = match.groupValues[2].toIntOrNull() ?: newCursor + } + DiffLineKind.HUNK + } + raw.startsWith("+") -> DiffLineKind.ADDITION + raw.startsWith("-") -> DiffLineKind.REMOVAL + else -> DiffLineKind.CONTEXT + } + when (kind) { + DiffLineKind.HUNK -> NumberedDiffLine(raw, kind, null, null) + DiffLineKind.ADDITION -> NumberedDiffLine(raw, kind, null, newCursor).also { newCursor++ } + DiffLineKind.REMOVAL -> NumberedDiffLine(raw, kind, oldCursor, null).also { oldCursor++ } + DiffLineKind.CONTEXT -> { + val numbered = NumberedDiffLine(raw, kind, oldCursor, newCursor) + oldCursor++ + newCursor++ + numbered + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt new file mode 100644 index 00000000..401146a7 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/NewThreadSheet.kt @@ -0,0 +1,235 @@ +package app.rxlab.rxcode.ui.sheets + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.ProjectBranchInfo +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import java.util.UUID + +/** + * New-thread bottom sheet — mirrors iOS `NewThreadSheet`. Shows the project's + * current and available branches (read from the most-recent + * `project_branches` snapshot), a plan-mode toggle, and an initial prompt + * field. The "Start" button is enabled as soon as we have a branch resolved. + * + * Picking a different branch from the current one isn't wired to + * `branch_op_request` yet — for now we surface the picker so the user can + * verify which branch the desktop will run on. A future change can switch the + * branch via `BranchOpRequestPayload.Operation.SWITCH_EXISTING` before the + * thread is created. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewThreadSheet( + projectId: UUID, + branchInfo: ProjectBranchInfo?, + preferredBranch: String? = null, + onDismiss: () -> Unit, + onSubmit: (planMode: Boolean, initialText: String, branch: String?) -> Unit, +) { + val haptics = rememberHaptics() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var planMode by remember { mutableStateOf(false) } + var prompt by remember { mutableStateOf("") } + val resolvedBranch = remember(branchInfo, preferredBranch) { + preferredBranch ?: branchInfo?.currentBranch + } + var selectedBranch by remember(resolvedBranch) { mutableStateOf(resolvedBranch) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("New thread", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + + // Branch row + BranchSelector( + branchInfo = branchInfo, + selected = selectedBranch, + onSelect = { + haptics.play(HapticEvent.Selection) + selectedBranch = it + }, + ) + + HorizontalDivider() + + ListItem( + headlineContent = { Text("Plan mode") }, + supportingContent = { + Text("Ask Claude to propose a plan before editing.") + }, + leadingContent = { + Icon(Icons.Outlined.Lightbulb, contentDescription = null) + }, + trailingContent = { + Switch( + checked = planMode, + onCheckedChange = { + haptics.play(HapticEvent.Selection) + planMode = it + }, + ) + }, + modifier = Modifier.padding(horizontal = 0.dp), + ) + + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + label = { Text("Initial message (optional)") }, + placeholder = { Text("What would you like to work on?") }, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = onDismiss, + modifier = Modifier.padding(vertical = 4.dp), + ) { Text("Cancel") } + Spacer(Modifier.weight(1f)) + Button( + onClick = { + haptics.play(HapticEvent.LightTap) + onSubmit(planMode, prompt.trim(), selectedBranch) + }, + enabled = true, + colors = ButtonDefaults.buttonColors(), + ) { Text("Start") } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun BranchSelector( + branchInfo: ProjectBranchInfo?, + selected: String?, + onSelect: (String) -> Unit, +) { + val branches = remember(branchInfo) { + val all = branchInfo?.availableBranches.orEmpty() + val current = branchInfo?.currentBranch + when { + all.isNotEmpty() && current != null -> listOf(current) + all.filter { it != current } + all.isNotEmpty() -> all + current != null -> listOf(current) + else -> emptyList() + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text("Branch", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + if (branches.isEmpty()) { + Text( + "No branch info yet — the thread will run on whatever branch the desktop has checked out.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + branches.forEach { branch -> + BranchRow( + branch = branch, + isSelected = branch == selected, + onClick = { onSelect(branch) }, + ) + } + } + } + } +} + +@Composable +private fun BranchRow( + branch: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + branch, + style = MaterialTheme.typography.bodyMedium, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/PermissionApprovalSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/PermissionApprovalSheet.kt new file mode 100644 index 00000000..9a4445ea --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/PermissionApprovalSheet.kt @@ -0,0 +1,144 @@ +package app.rxlab.rxcode.ui.sheets + +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.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Build +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.rxlab.rxcode.proto.PermissionRequestPayload +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +/** + * Bottom-sheet permission approval prompt. Replaces the lightweight + * `AlertDialog` previously used inside the chat screen so we can show the + * tool name prominently, render the payload JSON with monospace formatting, + * and give the user two distinct buttons (Allow / Deny). Mirrors + * `PermissionApprovalSheet` on iOS. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionApprovalSheet( + request: PermissionRequestPayload, + onAllow: () -> Unit, + onDeny: () -> Unit, + onDismiss: () -> Unit, +) { + val haptics = rememberHaptics() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val prettyPayload = remember(request.toolInputJSON) { + prettyPrintJson(request.toolInputJSON) + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Build, contentDescription = null) + } + } + Column(Modifier.weight(1f)) { + Text( + "Allow tool?", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + request.toolName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp), + ) { + Box(Modifier.verticalScroll(rememberScrollState()).padding(12.dp)) { + Text( + prettyPayload, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + haptics.play(HapticEvent.HeavyImpact) + onDeny() + }, + modifier = Modifier.weight(1f), + ) { Text("Deny") } + Button( + onClick = { + haptics.play(HapticEvent.LightTap) + onAllow() + }, + modifier = Modifier.weight(1f), + ) { Text("Allow") } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +/** + * Pretty-print the desktop's tool-input JSON for the sheet body. Falls back + * to the raw string if parsing fails so we never block on malformed data. + */ +private val prettyJson = Json { prettyPrint = true } + +private fun prettyPrintJson(raw: String): String { + return runCatching { + val element = Json.parseToJsonElement(raw) + prettyJson.encodeToString(JsonObject.serializer(), element.asJsonObject()) + }.getOrElse { raw } +} + +private fun kotlinx.serialization.json.JsonElement.asJsonObject(): JsonObject = + this as? JsonObject ?: JsonObject(mapOf("value" to this)) diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/QuestionSheet.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/QuestionSheet.kt new file mode 100644 index 00000000..a8544ed7 --- /dev/null +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sheets/QuestionSheet.kt @@ -0,0 +1,190 @@ +package app.rxlab.rxcode.ui.sheets + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +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.mutableStateMapOf +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.unit.dp +import app.rxlab.rxcode.proto.PendingQuestionPayload +import app.rxlab.rxcode.proto.QuestionAnswerEntry +import app.rxlab.rxcode.ui.util.HapticEvent +import app.rxlab.rxcode.ui.util.rememberHaptics +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Agent question sheet. The desktop ships a JSON-encoded + * `AskUserQuestion` tool payload via `question_queue`, and the user picks + * answers and submits them back as `QuestionAnswerEntry` rows. + * + * Supports radio (single-select), checkbox (multi-select), and a free-text + * "Other" field for each question. Mirrors `MobileQuestionSheet` on iOS. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuestionSheet( + pending: PendingQuestionPayload, + onDismiss: () -> Unit, + onSubmit: (answers: List) -> Unit, +) { + val haptics = rememberHaptics() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val parsed = remember(pending.toolInputJSON) { + runCatching { Json.decodeFromString(QuestionToolInput.serializer(), pending.toolInputJSON) } + .getOrNull() + ?: QuestionToolInput(questions = emptyList()) + } + val selections = remember { mutableStateMapOf>() } + val freeText = remember { mutableStateMapOf() } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Claude has a question", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + parsed.questions.forEachIndexed { index, q -> + QuestionBlock( + question = q, + selected = selections[index].orEmpty(), + onToggle = { value -> + val current = selections.getOrPut(index) { mutableListOf() } + if (q.multiSelect) { + if (value in current) current.remove(value) else current.add(value) + } else { + current.clear(); current.add(value) + } + selections[index] = current + haptics.play(HapticEvent.Selection) + }, + free = freeText[index].orEmpty(), + onFreeTextChange = { freeText[index] = it }, + ) + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.weight(1f)) + Button( + onClick = { + haptics.play(HapticEvent.LightTap) + val answers = parsed.questions.mapIndexed { index, q -> + val picks = selections[index].orEmpty().toMutableList() + val custom = freeText[index]?.trim().orEmpty() + if (custom.isNotEmpty()) picks.add(custom) + QuestionAnswerEntry( + questionIndex = index, + values = picks, + multiSelect = q.multiSelect, + ) + } + onSubmit(answers) + }, + enabled = parsed.questions.indices.all { idx -> + selections[idx]?.isNotEmpty() == true || + (freeText[idx]?.trim()?.isNotEmpty() == true) + }, + ) { Text("Submit") } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun QuestionBlock( + question: QuestionToolInput.Question, + selected: List, + onToggle: (String) -> Unit, + free: String, + onFreeTextChange: (String) -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(question.question, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + question.options.forEach { option -> + val checked = option.label in selected + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + ) { + if (question.multiSelect) { + Checkbox(checked = checked, onCheckedChange = { onToggle(option.label) }) + } else { + RadioButton(selected = checked, onClick = { onToggle(option.label) }) + } + Column(Modifier.padding(start = 6.dp)) { + Text(option.label, style = MaterialTheme.typography.bodyMedium) + if (!option.description.isNullOrBlank()) { + Text( + option.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + OutlinedTextField( + value = free, + onValueChange = onFreeTextChange, + label = { Text("Other (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } +} + +@Serializable +private data class QuestionToolInput(val questions: List = emptyList()) { + @Serializable + data class Question( + val question: String = "", + val header: String = "", + val multiSelect: Boolean = false, + val options: List