From 164d9d33000331fa0c50d774aadf21412f890acc Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 20 May 2026 22:42:23 +0800 Subject: [PATCH 1/7] feat: add tipkit support --- .../Sources/RxCodeChatKit/FeatureTips.swift | 22 +++ .../Sources/RxCodeChatKit/InputBarView.swift | 2 + RxCode/App/RxCodeApp.swift | 8 ++ RxCode/Views/MainView.swift | 3 + .../Views/Settings/ACPClientSettingsTab.swift | 2 + RxCode/Views/Settings/MCPSettingsTab.swift | 2 + RxCode/Views/Settings/MobileSettingsTab.swift | 2 + RxCode/Views/SettingsView.swift | 2 + RxCode/Views/Sidebar/ProjectTreeView.swift | 2 + RxCode/Views/Tips/RxCodeTips.swift | 130 ++++++++++++++++++ RxCodeMobile/RxCodeMobileApp.swift | 8 ++ RxCodeMobile/Views/MobileBriefingView.swift | 2 + RxCodeMobile/Views/MobileSettingsView.swift | 3 + RxCodeMobile/Views/MobileTips.swift | 130 ++++++++++++++++++ RxCodeMobile/Views/NewThreadSheet.swift | 5 +- RxCodeMobile/Views/OnboardingView.swift | 2 + RxCodeMobile/Views/ProjectsSidebar.swift | 4 + 17 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 Packages/Sources/RxCodeChatKit/FeatureTips.swift create mode 100644 RxCode/Views/Tips/RxCodeTips.swift create mode 100644 RxCodeMobile/Views/MobileTips.swift diff --git a/Packages/Sources/RxCodeChatKit/FeatureTips.swift b/Packages/Sources/RxCodeChatKit/FeatureTips.swift new file mode 100644 index 0000000..604878f --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/FeatureTips.swift @@ -0,0 +1,22 @@ +import SwiftUI +import TipKit + +enum ChatFeatureTips { + struct PlanModeTip: Tip { + var title: Text { + Text("Start in plan mode") + } + + var message: Text? { + Text("Use the Add menu or Shift-Tab to ask the agent for a read-only plan before edits begin.") + } + + var image: Image? { + Image(systemName: "checklist") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index d904b5a..5778532 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import TipKit import UniformTypeIdentifiers import RxCodeCore @@ -246,6 +247,7 @@ struct InputBarView: View { .fixedSize() .help(windowState.sessionPlanMode ? "Plan mode is on — Add menu" : "Add — attach file or toggle plan mode") .accessibilityIdentifier("composer-add-menu") + .popoverTip(ChatFeatureTips.PlanModeTip(), arrowEdge: .top) .fileImporter( isPresented: $showFilePicker, allowedContentTypes: [.item], diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 017dff2..fec7720 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -1,6 +1,7 @@ import SwiftUI import RxCodeCore import RxCodeChatKit +import TipKit // MARK: - FocusedValues @@ -37,6 +38,13 @@ struct RxCodeApp: App { @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true private let updateService = UpdateService.shared + init() { + try? Tips.configure([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault), + ]) + } + var body: some Scene { WindowGroup { MainWindowRoot(appState: appState) diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index b3d0c8f..f068ab8 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -2,6 +2,7 @@ import AppKit import RxCodeChatKit import RxCodeCore import SwiftUI +import TipKit import UniformTypeIdentifiers struct MainView: View { @@ -202,6 +203,7 @@ struct MainView: View { Image(systemName: "magnifyingglass") } .help("Search Threads (⌘K)") + .popoverTip(RxCodeTips.GlobalSearchTip(), arrowEdge: .top) } ToolbarItem(placement: .navigation) { @@ -588,6 +590,7 @@ struct ChatToolbarControls: View { .fixedSize() .help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))") .accessibilityIdentifier("provider-model-menu") + .popoverTip(RxCodeTips.AgentSelectionTip(), arrowEdge: .top) Menu { Section("Effort Picker") { diff --git a/RxCode/Views/Settings/ACPClientSettingsTab.swift b/RxCode/Views/Settings/ACPClientSettingsTab.swift index 8c1187e..3d37e3f 100644 --- a/RxCode/Views/Settings/ACPClientSettingsTab.swift +++ b/RxCode/Views/Settings/ACPClientSettingsTab.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit private enum ACPClientSettingsPage: String, CaseIterable, Identifiable { case installed @@ -105,6 +106,7 @@ struct ACPClientSettingsTab: View { .labelsHidden() .frame(width: 240) .frame(maxWidth: .infinity, alignment: .center) + .popoverTip(RxCodeTips.ACPTip(), arrowEdge: .top) } // MARK: - Installed diff --git a/RxCode/Views/Settings/MCPSettingsTab.swift b/RxCode/Views/Settings/MCPSettingsTab.swift index 18c953c..d6a1457 100644 --- a/RxCode/Views/Settings/MCPSettingsTab.swift +++ b/RxCode/Views/Settings/MCPSettingsTab.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit struct MCPSettingsTab: View { @Environment(AppState.self) private var appState @@ -85,6 +86,7 @@ struct MCPSettingsTab: View { .font(.system(size: ClaudeTheme.size(12))) } .buttonStyle(.borderedProminent) + .popoverTip(RxCodeTips.MCPTip(), arrowEdge: .top) } } diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 2e07cda..fedae54 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -2,6 +2,7 @@ import SwiftUI import CoreImage.CIFilterBuiltins import RxCodeCore import RxCodeSync +import TipKit /// How the relay server is chosen in the Mobile settings tab. private enum RelayMode: Hashable { @@ -243,6 +244,7 @@ struct MobileSettingsTab: View { .buttonStyle(.borderedProminent) .disabled(sync.connectionState != .connected) .help(sync.connectionState == .connected ? "" : "Connect to the relay before pairing a device.") + .popoverTip(RxCodeTips.MobileConnectionTip(), arrowEdge: .top) } if sync.pairedDevices.isEmpty { diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 1b236a4..38e516e 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import RxCodeCore import RxCodeChatKit +import TipKit // MARK: - Settings Sheet @@ -684,6 +685,7 @@ struct ChatSettingsTab: View { } .pickerStyle(.menu) .fixedSize() + .popoverTip(RxCodeTips.SummarizationModelTip(), arrowEdge: .trailing) .onChange(of: appState.summarizationProvider) { _, newValue in guard newValue == .openAI, appState.openAISummarizationModels.isEmpty else { return } Task { await appState.refreshOpenAISummarizationModels() } diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index ea2f9c5..d98d770 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit // MARK: - ProjectTreeView @@ -178,6 +179,7 @@ private struct SummarySidebarSection: View { } .buttonStyle(.plain) .help("Open project branch briefing") + .popoverTip(RxCodeTips.BriefingTip(), arrowEdge: .trailing) } } } diff --git a/RxCode/Views/Tips/RxCodeTips.swift b/RxCode/Views/Tips/RxCodeTips.swift new file mode 100644 index 0000000..38e3566 --- /dev/null +++ b/RxCode/Views/Tips/RxCodeTips.swift @@ -0,0 +1,130 @@ +import SwiftUI +import TipKit + +enum RxCodeTips { + struct AgentSelectionTip: Tip { + var title: Text { + Text("Choose the agent for this thread") + } + + var message: Text? { + Text("Switch between Claude Code, Codex, and installed ACP agents without changing the global default.") + } + + var image: Image? { + Image(systemName: "sparkles") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct MCPTip: Tip { + var title: Text { + Text("Add tools with MCP") + } + + var message: Text? { + Text("Manage MCP servers once in RxCode, then enable or disable them globally or per project.") + } + + var image: Image? { + Image(systemName: "puzzlepiece.extension") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct ACPTip: Tip { + var title: Text { + Text("Install ACP agents") + } + + var message: Text? { + Text("Use the registry to add Agent Client Protocol tools, then pick them from the thread model menu.") + } + + var image: Image? { + Image(systemName: "link.circle") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct MobileConnectionTip: Tip { + var title: Text { + Text("Connect the mobile companion") + } + + var message: Text? { + Text("Pair an iPhone or iPad to review threads, answer approvals, and send messages to your desktop agent.") + } + + var image: Image? { + Image(systemName: "iphone.gen3") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SummarizationModelTip: Tip { + var title: Text { + Text("Set the summarization model") + } + + var message: Text? { + Text("Keep summaries on the thread model, or route titles and briefings through an OpenAI-compatible endpoint.") + } + + var image: Image? { + Image(systemName: "text.badge.checkmark") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct BriefingTip: Tip { + var title: Text { + Text("Open the branch briefing") + } + + var message: Text? { + Text("Briefing rolls recent thread summaries into a branch-focused project update.") + } + + var image: Image? { + Image(systemName: "text.page") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct GlobalSearchTip: Tip { + var title: Text { + Text("Search every thread") + } + + var message: Text? { + Text("Press Command-K or use the toolbar search button to find past work across projects.") + } + + var image: Image? { + Image(systemName: "magnifyingglass") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index d6900f0..e39272e 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -1,5 +1,6 @@ import SwiftUI import RxCodeCore +import TipKit @main struct RxCodeMobileApp: App { @@ -8,6 +9,13 @@ struct RxCodeMobileApp: App { @State private var windowState = WindowState() @Environment(\.scenePhase) private var scenePhase + init() { + try? Tips.configure([ + .displayFrequency(.immediate), + .datastoreLocation(.applicationDefault), + ]) + } + var body: some Scene { WindowGroup { RootView() diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index d9ddc79..7b12ca5 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -2,6 +2,7 @@ import SwiftUI import RxCodeCore import RxCodeChatKit import RxCodeSync +import TipKit struct BriefingGroupKey: Hashable { let projectId: UUID @@ -64,6 +65,7 @@ struct MobileBriefingView: View { .scrollContentBackground(.hidden) } .navigationTitle("Briefing") + .popoverTip(MobileTips.BriefingTip(), arrowEdge: .top) .toolbar { if hasAnyData { ToolbarItem(placement: .topBarTrailing) { diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 3dfc240..522c931 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -1,6 +1,7 @@ import RxCodeCore import RxCodeSync import SwiftUI +import TipKit struct MobileSettingsView: View { @EnvironmentObject private var state: MobileAppState @@ -156,6 +157,7 @@ struct MobileSettingsView: View { } label: { Label("Pair New Mac", systemImage: "plus.circle") } + .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) } } @@ -333,6 +335,7 @@ struct MobileSettingsView: View { } } .pickerStyle(.menu) + .popoverTip(MobileTips.SummarizationTip(), arrowEdge: .top) if settings.summarizationProvider == "openAI" { if !settings.openAISummarizationEndpoint.isEmpty { diff --git a/RxCodeMobile/Views/MobileTips.swift b/RxCodeMobile/Views/MobileTips.swift new file mode 100644 index 0000000..2a6019c --- /dev/null +++ b/RxCodeMobile/Views/MobileTips.swift @@ -0,0 +1,130 @@ +import SwiftUI +import TipKit + +enum MobileTips { + struct PairingTip: Tip { + var title: Text { + Text("Pair with your Mac") + } + + var message: Text? { + Text("Scan the QR code from RxCode Settings on your Mac to control threads and approvals from this device.") + } + + var image: Image? { + Image(systemName: "qrcode.viewfinder") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct BriefingTip: Tip { + var title: Text { + Text("Review project briefings") + } + + var message: Text? { + Text("Branch briefings collect recent thread summaries from your Mac into a quick mobile status view.") + } + + var image: Image? { + Image(systemName: "doc.text") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SearchTip: Tip { + var title: Text { + Text("Search projects and threads") + } + + var message: Text? { + Text("Use mobile search to find synced projects, thread titles, and desktop search hits.") + } + + var image: Image? { + Image(systemName: "magnifyingglass") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct RemoteProjectTip: Tip { + var title: Text { + Text("Add a project from your Mac") + } + + var message: Text? { + Text("Pick a desktop folder remotely, then start or continue its threads from mobile.") + } + + var image: Image? { + Image(systemName: "folder.badge.plus") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct AgentSelectionTip: Tip { + var title: Text { + Text("Choose the thread agent") + } + + var message: Text? { + Text("Start a mobile-created thread with Claude Code, Codex, or an ACP agent synced from your Mac.") + } + + var image: Image? { + Image(systemName: "sparkles") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct PlanModeTip: Tip { + var title: Text { + Text("Start with a plan") + } + + var message: Text? { + Text("Enable Plan before sending a new thread so the desktop agent drafts a read-only plan first.") + } + + var image: Image? { + Image(systemName: "checklist") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } + + struct SummarizationTip: Tip { + var title: Text { + Text("Control desktop summaries") + } + + var message: Text? { + Text("Change the summarization provider from mobile; API keys and endpoint secrets stay on the Mac.") + } + + var image: Image? { + Image(systemName: "text.badge.checkmark") + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(1) + } + } +} diff --git a/RxCodeMobile/Views/NewThreadSheet.swift b/RxCodeMobile/Views/NewThreadSheet.swift index 5d46edb..d5d58b4 100644 --- a/RxCodeMobile/Views/NewThreadSheet.swift +++ b/RxCodeMobile/Views/NewThreadSheet.swift @@ -2,6 +2,7 @@ import SwiftUI import UIKit import RxCodeCore import RxCodeSync +import TipKit /// Modal sheet for composing a new thread. Captures the prompt + config knobs /// (branch, model, permission mode), fires `requestNewSession` on submit, then @@ -72,7 +73,7 @@ struct NewThreadSheet: View { } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .interactiveDismissDisabled(isSubmitting) + .interactiveDismissDisabled(true) .onAppear { seedConfigIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { @@ -409,6 +410,7 @@ struct NewThreadConfigStrip: View { chipLabel(icon: "cpu", title: selectedModelLabel) } .disabled(allModels.isEmpty) + .popoverTip(MobileTips.AgentSelectionTip(), arrowEdge: .top) } private func applyModel(_ model: AgentModel) { @@ -460,6 +462,7 @@ struct NewThreadConfigStrip: View { .buttonStyle(.plain) .accessibilityLabel("Plan mode") .accessibilityValue(planModeEnabled ? "On" : "Off") + .popoverTip(MobileTips.PlanModeTip(), arrowEdge: .top) } // MARK: Chip diff --git a/RxCodeMobile/Views/OnboardingView.swift b/RxCodeMobile/Views/OnboardingView.swift index 03415ca..3410dda 100644 --- a/RxCodeMobile/Views/OnboardingView.swift +++ b/RxCodeMobile/Views/OnboardingView.swift @@ -2,6 +2,7 @@ import SwiftUI import PhotosUI import RxCodeSync import os +import TipKit private let onboardingLogger = Logger(subsystem: "com.claudework", category: "Onboarding") @@ -240,6 +241,7 @@ struct OnboardingView: View { .buttonStyle(.glassProminent) .controlSize(.large) .tint(.accentColor) + .popoverTip(MobileTips.PairingTip(), arrowEdge: .top) } private func errorBanner(_ message: String) -> some View { diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index 7ddcfbb..634678c 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -1,6 +1,7 @@ import SwiftUI import RxCodeCore import RxCodeSync +import TipKit struct ProjectsSidebar: View { @EnvironmentObject private var state: MobileAppState @@ -23,6 +24,7 @@ struct ProjectsSidebar: View { Image(systemName: "folder.badge.plus") } .accessibilityLabel("Add Project") + .popoverTip(MobileTips.RemoteProjectTip(), arrowEdge: .top) } } .sheet(isPresented: $showingRemoteFolderPicker) { @@ -37,6 +39,7 @@ struct ProjectsSidebar: View { placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search projects and threads" ) + .popoverTip(MobileTips.SearchTip(), arrowEdge: .top) .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .onChange(of: searchText) { _, newValue in @@ -85,6 +88,7 @@ struct ProjectsSidebar: View { .font(.headline) .foregroundStyle(showingBriefing ? Color.accentColor : Color.primary) } + .popoverTip(MobileTips.BriefingTip(), arrowEdge: .trailing) } Section("Projects") { From ee7e52b6559070d7dfa54b16d77dd3a85827d387 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 20 May 2026 23:28:16 +0800 Subject: [PATCH 2/7] feat: add widget extension with Live Activity for job tracking Add RxCodeWidget extension with Live Activity support to display running job status, including the MobileLiveActivityCoordinator and app group entitlements for sharing data between the app and widget. Co-Authored-By: Claude Opus 4.7 --- .../Sources/RxCodeChatKit/MarkdownView.swift | 43 +-- .../RxCodeChatKit/MessageListView.swift | 9 - .../Backend/BackendCapability.swift | 7 +- .../RxCodeCore/Models/MarketplacePlugin.swift | 70 +++- .../Sources/RxCodeSync/Protocol/Payload.swift | 35 ++ README.md | 2 +- RxCode.xcodeproj/project.pbxproj | 173 +++++++++ .../xcschemes/RxCodeMobile.xcscheme | 24 +- RxCode/App/AppState.swift | 27 +- RxCode/App/RxCodeApp.swift | 8 +- RxCode/Services/MarketplaceService.swift | 178 +++++++++- RxCode/Services/MobileSyncService.swift | 331 ++++++++++++++++++ RxCode/Views/Chat/SkillMarketView.swift | 57 +-- RxCode/Views/SettingsView.swift | 52 +-- RxCode/Views/UserManualView.swift | 6 +- RxCodeMobile/AppDelegate.swift | 29 ++ RxCodeMobile/Info.plist | 4 + RxCodeMobile/RxCodeMobile.entitlements | 4 + RxCodeMobile/RxCodeMobileApp.swift | 2 + RxCodeMobile/State/MobileAppState.swift | 33 ++ .../State/MobileLiveActivityCoordinator.swift | 150 ++++++++ RxCodeMobile/Views/QRScannerView.swift | 24 ++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ RxCodeWidget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + RxCodeWidget/Info.plist | 11 + RxCodeWidget/RxCodeJobActivity.swift | 61 ++++ RxCodeWidget/RxCodeWidget.entitlements | 10 + RxCodeWidget/RxCodeWidget.swift | 190 ++++++++++ RxCodeWidget/RxCodeWidgetBundle.swift | 17 + RxCodeWidget/RxCodeWidgetData.swift | 64 ++++ RxCodeWidget/RxCodeWidgetLiveActivity.swift | 198 +++++++++++ relay-server/README.md | 42 ++- relay-server/push.go | 194 ++++++---- 35 files changed, 1884 insertions(+), 234 deletions(-) create mode 100644 RxCodeMobile/State/MobileLiveActivityCoordinator.swift create mode 100644 RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 RxCodeWidget/Assets.xcassets/Contents.json create mode 100644 RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 RxCodeWidget/Info.plist create mode 100644 RxCodeWidget/RxCodeJobActivity.swift create mode 100644 RxCodeWidget/RxCodeWidget.entitlements create mode 100644 RxCodeWidget/RxCodeWidget.swift create mode 100644 RxCodeWidget/RxCodeWidgetBundle.swift create mode 100644 RxCodeWidget/RxCodeWidgetData.swift create mode 100644 RxCodeWidget/RxCodeWidgetLiveActivity.swift diff --git a/Packages/Sources/RxCodeChatKit/MarkdownView.swift b/Packages/Sources/RxCodeChatKit/MarkdownView.swift index 2b2e331..ebd4c4c 100644 --- a/Packages/Sources/RxCodeChatKit/MarkdownView.swift +++ b/Packages/Sources/RxCodeChatKit/MarkdownView.swift @@ -12,9 +12,6 @@ struct MarkdownContentView: View { let showsTrailingCursor: Bool let isCursorVisible: Bool - /// `true` while the chat list is scrolling — see `markdownTextSelection`. - @Environment(\.chatListScrollActive) private var isScrollActive - init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) { self.text = text self.showsTrailingCursor = showsTrailingCursor @@ -37,7 +34,15 @@ struct MarkdownContentView: View { ) .textual.headingStyle(RxCodeHeadingStyle()) .textual.codeBlockStyle(RxCodeBlockStyle()) - .markdownTextSelection(enabled: !isScrollActive) + // Keep Textual's text-selection overlay permanently disabled. + // When enabled it installs a geometry-dependent + // `onChange(of: AnyTextLayoutCollection)` that fires many times + // per frame while the chat List scrolls, dropping frames and + // making the scroll bumpy. Toggling it per scroll-phase is worse: + // flipping selectability swaps Textual's view-tree branch and + // rebuilds every visible markdown row. Whole-message and + // per-code-block Copy buttons cover copying instead. + .textual.textSelection(.disabled) .frame(maxWidth: .infinity, alignment: .leading) } @@ -52,36 +57,6 @@ struct MarkdownContentView: View { } } -// MARK: - Text Selection Toggle - -extension EnvironmentValues { - /// `true` while the chat message list is actively scrolling. - /// - /// Markdown rows read this to drop Textual's text-selection overlay - /// mid-scroll — see `View.markdownTextSelection(enabled:)`. - @Entry var chatListScrollActive: Bool = false -} - -private extension View { - /// Applies Textual text selection, gated by `enabled`. - /// - /// Textual's selection overlay installs a per-message `Text.LayoutKey` - /// preference observer whose `onChange` mutates an `@Observable` model on - /// every layout pass. While a `List` scrolls, that fires repeatedly within - /// a single frame ("onChange(of: AnyTextLayoutCollection) ... tried to - /// update multiple times per frame"), dropping frames and making the - /// scroll bumpy. Suspending selection during scroll removes the overlay - /// entirely; it is restored the instant the list settles. - @ViewBuilder - func markdownTextSelection(enabled: Bool) -> some View { - if enabled { - textual.textSelection(.enabled) - } else { - textual.textSelection(.disabled) - } - } -} - // MARK: - Markdown Preprocessing /// Applies bare-URL auto-linking and link sanitization, skipping fenced code blocks diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index ea9b55d..21af677 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -17,9 +17,6 @@ struct MessageListView: View { @State private var readyTask: Task? @State private var anchor = AutoScrollAnchor() @State private var isSessionReady = false - /// Tracks active scrolling so markdown rows can suspend Textual's - /// text-selection overlay mid-scroll (avoids per-frame layout cycles). - @State private var isScrollActive = false private static let log = Logger(subsystem: "com.claudework", category: "MessageListView") private static let bottomAnchorID = "message-list-bottom-anchor" @@ -71,7 +68,6 @@ struct MessageListView: View { .contentMargins(.top, 16, for: .scrollContent) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 0) - .environment(\.chatListScrollActive, isScrollActive) .opacity(isSessionReady ? 1 : 0) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollSample.self) { geo in @@ -86,11 +82,6 @@ struct MessageListView: View { scrollToBottomDebounced(proxy) } } - .onScrollPhaseChange { _, newPhase in - // Suspend Textual text selection while the list is in motion and - // restore it the instant scrolling settles back to `.idle`. - isScrollActive = newPhase != .idle - } .task(id: windowState.currentSessionId) { let sid = windowState.currentSessionId ?? "" Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)") diff --git a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift index 636518f..b308c90 100644 --- a/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift +++ b/Packages/Sources/RxCodeCore/Backend/BackendCapability.swift @@ -13,6 +13,7 @@ public enum BackendCapability: String, Sendable, Hashable, CaseIterable, Codable case attachments case hooks case mcpServers + case skills } public typealias CapabilitySet = Set @@ -26,16 +27,16 @@ public extension AgentProvider { case .claudeCode: return [ .askUserQuestion, .todos, .planMode, .fileEdit, .hooks, - .mcpServers, .attachments, .customSlashCommands, .getUsage, + .mcpServers, .skills, .attachments, .customSlashCommands, .getUsage, ] case .codex: return [ .askUserQuestion, .todos, .planMode, .fileEdit, - .mcpServers, .attachments, .getUsage, + .mcpServers, .skills, .attachments, .getUsage, ] case .acp: return [ - .planMode, .fileEdit, .mcpServers, .attachments, .getUsage, + .planMode, .fileEdit, .mcpServers, .skills, .attachments, .getUsage, ] } } diff --git a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift index e62f15b..9042847 100644 --- a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift +++ b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift @@ -8,17 +8,20 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { public let category: String public let homepage: String public let marketplace: String + public let marketplaceSource: MarketplaceSource? public let sourceType: SourceType public let skillPaths: [String] public init(name: String, description: String, author: String, category: String, - homepage: String, marketplace: String, sourceType: SourceType, skillPaths: [String]) { + homepage: String, marketplace: String, marketplaceSource: MarketplaceSource? = nil, + sourceType: SourceType, skillPaths: [String]) { self.name = name self.description = description self.author = author self.category = category self.homepage = homepage self.marketplace = marketplace + self.marketplaceSource = marketplaceSource self.sourceType = sourceType self.skillPaths = skillPaths } @@ -58,6 +61,71 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { } } +public struct MarketplaceSource: Codable, Sendable, Hashable { + public let owner: String + public let repo: String + public let ref: String? + + public init(owner: String, repo: String, ref: String? = nil) { + self.owner = owner + self.repo = repo + self.ref = ref + } + + public var codexSource: String { + if let ref, !ref.isEmpty { + return "\(owner)/\(repo)@\(ref)" + } + return "\(owner)/\(repo)" + } +} + +public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable { + public var id: String { "\(marketplace)/\(name)" } + public var name: String + public var marketplace: String + public var summary: String? + public var category: String? + public var marketplaceSource: MarketplaceSource? + public var installedAt: Date + public var isGloballyEnabled: Bool + public var enabledProviders: Set + + public init( + name: String, + marketplace: String, + summary: String? = nil, + category: String? = nil, + marketplaceSource: MarketplaceSource?, + installedAt: Date = Date(), + isGloballyEnabled: Bool = true, + enabledProviders: Set = Set(AgentProvider.allCases) + ) { + self.name = name + self.marketplace = marketplace + self.summary = summary + self.category = category + self.marketplaceSource = marketplaceSource + self.installedAt = installedAt + self.isGloballyEnabled = isGloballyEnabled + self.enabledProviders = enabledProviders + } + + public func isEnabled(for provider: AgentProvider) -> Bool { + isGloballyEnabled && enabledProviders.contains(provider) + } +} + +public struct MarketplacePluginConfiguration: Codable, Sendable, Equatable { + public var version: Int + public var plugins: [MarketplacePluginRecord] + + public init(version: Int = 1, plugins: [MarketplacePluginRecord] = []) { + self.version = version + self.plugins = plugins + } +} + public enum PluginInstallStatus: Sendable { case notInstalled case installing diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index c458c2a..474c315 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -11,6 +11,7 @@ public enum Payload: Sendable { case pairAck(PairAckPayload) case unpair(UnpairPayload) case apnsToken(APNsTokenPayload) + case liveActivityToken(LiveActivityTokenPayload) case requestSnapshot(RequestSnapshotPayload) case snapshot(SnapshotPayload) case settingsUpdate(MobileSettingsUpdatePayload) @@ -54,6 +55,7 @@ public extension Payload { case .pairAck: return "pair_ack" case .unpair: return "unpair" case .apnsToken: return "apns_token" + case .liveActivityToken: return "live_activity_token" case .requestSnapshot: return "request_snapshot" case .snapshot: return "snapshot" case .settingsUpdate: return "settings_update" @@ -134,6 +136,36 @@ public struct APNsTokenPayload: Codable, Sendable { } } +/// Mobile → desktop: ActivityKit push tokens for the job Live Activity. A +/// single payload reports either the device-wide push-to-start token, a +/// per-activity update token, or both. The desktop stores them per paired +/// device so it can remotely start, update, and end Live Activities over APNs. +public struct LiveActivityTokenPayload: Codable, Sendable { + /// Device-wide push-to-start token (iOS 17.2+). Lets the desktop start a + /// Live Activity for a new job remotely. `nil` when this payload only + /// reports a per-activity update token. + public let pushToStartTokenHex: String? + /// Per-activity update token returned by `Activity.pushTokenUpdates`. + /// `nil` when this payload only reports a push-to-start token. + public let activityTokenHex: String? + /// Identifier of the `Activity` the update token belongs to. + public let activityID: String? + /// The job (chat session) the activity tracks. + public let sessionID: String? + + public init( + pushToStartTokenHex: String? = nil, + activityTokenHex: String? = nil, + activityID: String? = nil, + sessionID: String? = nil + ) { + self.pushToStartTokenHex = pushToStartTokenHex + self.activityTokenHex = activityTokenHex + self.activityID = activityID + self.sessionID = sessionID + } +} + public struct RequestSnapshotPayload: Codable, Sendable { public let activeSessionID: String? public init(activeSessionID: String? = nil) { @@ -1393,6 +1425,7 @@ extension Payload: Codable { case pairAck = "pair_ack" case unpair case apnsToken = "apns_token" + case liveActivityToken = "live_activity_token" case requestSnapshot = "request_snapshot" case snapshot case settingsUpdate = "settings_update" @@ -1440,6 +1473,7 @@ extension Payload: Codable { case .pairAck: self = .pairAck(try container.decode(PairAckPayload.self, forKey: .data)) case .unpair: self = .unpair(try container.decode(UnpairPayload.self, forKey: .data)) case .apnsToken: self = .apnsToken(try container.decode(APNsTokenPayload.self, forKey: .data)) + case .liveActivityToken: self = .liveActivityToken(try container.decode(LiveActivityTokenPayload.self, forKey: .data)) case .requestSnapshot: self = .requestSnapshot(try container.decode(RequestSnapshotPayload.self, forKey: .data)) case .snapshot: self = .snapshot(try container.decode(SnapshotPayload.self, forKey: .data)) case .settingsUpdate: self = .settingsUpdate(try container.decode(MobileSettingsUpdatePayload.self, forKey: .data)) @@ -1483,6 +1517,7 @@ extension Payload: Codable { case .pairAck(let p): try container.encode(TypeKey.pairAck.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unpair(let p): try container.encode(TypeKey.unpair.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .apnsToken(let p): try container.encode(TypeKey.apnsToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .liveActivityToken(let p): try container.encode(TypeKey.liveActivityToken.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .requestSnapshot(let p): try container.encode(TypeKey.requestSnapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .snapshot(let p): try container.encode(TypeKey.snapshot.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .settingsUpdate(let p): try container.encode(TypeKey.settingsUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/README.md b/README.md index 15ee75e..22e59d7 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Same agents, no terminal required. | **Git Status** | Sidebar Git status summary with changed-file counts, branch display, and local/remote branch switching. | | **GitHub Integration** | OAuth device flow, Keychain token storage, SSH key management, repository browsing, and cloning. | | **Memo Panel** | Per-project rich-text memo pad with headings, lists, checkboxes, links, and persistent storage. | -| **Skill Marketplace** | Browse and install official Anthropic plugins, refreshed with a 5-minute cache. | +| **Skill Marketplace** | Browse and install agent skills from Settings, refreshed with a 5-minute cache and enabled for supported coding agents. | | **Themes and Font Controls** | Six accent themes plus independent font size controls for the interface and message area. | | **Focus Mode** | Optional focused chat layout that can be enabled from Settings. | | **Notifications** | Optional system notifications with response previews while RxCode is in the background. | diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index ad08977..6dd4681 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ DCA8CF4A05C959C4A6EB391F /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = CCDF9594876099576D4FD46E /* RxCodeCore */; }; DF06CCD12FB4CAB5005991E1 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = DF06CCD02FB4CAB5005991E1 /* ViewInspector */; }; DF06DCC72FB8552B005991E1 /* UnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */; }; + DF22D8282FBE025C00E3ABFD /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */; }; + DF22D82A2FBE025C00E3ABFD /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */; }; + DF22D8372FBE025D00E3ABFD /* RxCodeWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF230B992FBC738D008929A6 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B982FBC738D008929A6 /* RxCodeChatKit */; }; DF230B9B2FBC738D008929A6 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9A2FBC738D008929A6 /* RxCodeCore */; }; DF230B9D2FBC738D008929A6 /* RxCodeSync in Frameworks */ = {isa = PBXBuildFile; productRef = DF230B9C2FBC738D008929A6 /* RxCodeSync */; }; @@ -50,6 +53,13 @@ remoteGlobalIDString = E67335372F7356F600FD26C7; remoteInfo = RxCode; }; + DF22D8352FBE025D00E3ABFD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E67335302F7356F600FD26C7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DF22D8252FBE025C00E3ABFD; + remoteInfo = RxCodeWidgetExtension; + }; DF230B602FBC7368008929A6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E67335302F7356F600FD26C7 /* Project object */; @@ -81,6 +91,7 @@ dstSubfolderSpec = 13; files = ( DF230BB42FBC9001008929A6 /* RxCodeMobileNotificationService.appex in Embed App Extensions */, + DF22D8372FBE025D00E3ABFD /* RxCodeWidgetExtension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -95,6 +106,9 @@ 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTestPlan.xctestplan; sourceTree = ""; }; + DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; DF230B4F2FBC7367008929A6 /* RxCodeMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCodeMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -107,6 +121,21 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */; + }; + DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + RxCodeJobActivity.swift, + RxCodeWidgetData.swift, + ); + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + }; DF230B772FBC7368008929A6 /* Exceptions for "RxCodeMobile" folder in "RxCodeMobile" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -124,6 +153,15 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */, + DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */, + ); + path = RxCodeWidget; + sourceTree = ""; + }; DF230B502FBC7367008929A6 /* RxCodeMobile */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -176,6 +214,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8232FBE025C00E3ABFD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DF22D82A2FBE025C00E3ABFD /* SwiftUI.framework in Frameworks */, + DF22D8282FBE025C00E3ABFD /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4C2FBC7367008929A6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -250,6 +297,8 @@ isa = PBXGroup; children = ( 50F6C20C7EE8F07B95128612 /* OS X */, + DF22D8272FBE025C00E3ABFD /* WidgetKit.framework */, + DF22D8292FBE025C00E3ABFD /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -274,6 +323,7 @@ DF230BA92FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B622FBC7368008929A6 /* RxCodeMobileTests */, DF230B6C2FBC7368008929A6 /* RxCodeMobileUITests */, + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */, E67335392F7356F600FD26C7 /* Products */, 609E8EE085862BD7D5B4012F /* Frameworks */, 1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */, @@ -290,6 +340,7 @@ DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */, DF230B5F2FBC7368008929A6 /* RxCodeMobileTests.xctest */, DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */, + DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -340,6 +391,28 @@ productReference = 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DF22D8382FBE025D00E3ABFD /* Build configuration list for PBXNativeTarget "RxCodeWidgetExtension" */; + buildPhases = ( + DF22D8222FBE025C00E3ABFD /* Sources */, + DF22D8232FBE025C00E3ABFD /* Frameworks */, + DF22D8242FBE025C00E3ABFD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */, + ); + name = RxCodeWidgetExtension; + packageProductDependencies = ( + ); + productName = RxCodeWidgetExtension; + productReference = DF22D8262FBE025C00E3ABFD /* RxCodeWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DF230B4E2FBC7367008929A6 /* RxCodeMobile */ = { isa = PBXNativeTarget; buildConfigurationList = DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */; @@ -353,6 +426,7 @@ ); dependencies = ( DF230BB72FBC9001008929A6 /* PBXTargetDependency */, + DF22D8362FBE025D00E3ABFD /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DF230B502FBC7367008929A6 /* RxCodeMobile */, @@ -477,6 +551,9 @@ LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { + DF22D8252FBE025C00E3ABFD = { + CreatedOnToolsVersion = 26.3; + }; DF230B4E2FBC7367008929A6 = { CreatedOnToolsVersion = 26.3; }; @@ -525,6 +602,7 @@ DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */, DF230B5E2FBC7368008929A6 /* RxCodeMobileTests */, DF230B682FBC7368008929A6 /* RxCodeMobileUITests */, + DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */, ); }; /* End PBXProject section */ @@ -544,6 +622,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8242FBE025C00E3ABFD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4D2FBC7367008929A6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -604,6 +689,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF22D8222FBE025C00E3ABFD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DF230B4B2FBC7367008929A6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -654,6 +746,11 @@ target = E67335372F7356F600FD26C7 /* RxCode */; targetProxy = 35C1B17CDEF83F212F648418 /* PBXContainerItemProxy */; }; + DF22D8362FBE025D00E3ABFD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */; + targetProxy = DF22D8352FBE025D00E3ABFD /* PBXContainerItemProxy */; + }; DF230B612FBC7368008929A6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; @@ -728,6 +825,73 @@ }; name = Release; }; + DF22D8392FBE025D00E3ABFD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RxCodeWidget/RxCodeWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RxCodeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile.RxCodeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DF22D83A2FBE025D00E3ABFD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RxCodeWidget/RxCodeWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T7GYB573Y6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RxCodeWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RxCodeWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.rxlab.rxcodemobile.RxCodeWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; DF230B712FBC7368008929A6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1167,6 +1331,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DF22D8382FBE025D00E3ABFD /* Build configuration list for PBXNativeTarget "RxCodeWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DF22D8392FBE025D00E3ABFD /* Debug */, + DF22D83A2FBE025D00E3ABFD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DF230B782FBC7368008929A6 /* Build configuration list for PBXNativeTarget "RxCodeMobile" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme index 47ad7a7..483cebc 100644 --- a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme +++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> + + + + + + + + ? = nil @@ -4293,6 +4306,7 @@ final class AppState { mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) case .codex: mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd) + mcpCodexOverrides += await marketplace.codexConfigOverrides() resolvedSendMode = registerMode case .acp: // Allocate a per-session IDE-MCP port so the ACP agent can call @@ -4308,6 +4322,9 @@ final class AppState { projectPath: cwd, bridgeCommand: bridge ) + if let skillContext = await marketplace.promptContext(for: .acp) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + } // `model` may be a composite `::` key (from the picker) // or a bare model id (from a per-session override). let split = acpSelectionParts(for: model) @@ -4342,7 +4359,7 @@ final class AppState { } else { let request = BackendSendRequest( streamId: streamId, - prompt: prompt, + prompt: resolvedPrompt, cwd: cwd, sessionId: cliSessionId, model: resolvedModel, @@ -6814,8 +6831,12 @@ final class AppState { async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) async let installed = marketplace.installedPluginNames() - marketplaceCatalog = await catalog - marketplaceInstalledNames = await installed + let fetchedCatalog = await catalog + let installedNames = await installed + await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames) + + marketplaceCatalog = fetchedCatalog + marketplaceInstalledNames = installedNames } func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index fec7720..d7bfd3c 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -1,6 +1,6 @@ -import SwiftUI -import RxCodeCore import RxCodeChatKit +import RxCodeCore +import SwiftUI import TipKit // MARK: - FocusedValues @@ -446,7 +446,7 @@ private struct MenuBarUsageBar: View { switch percent { case ..<60: return ClaudeTheme.accent case ..<85: return .orange - default: return .red + default: return .red } } @@ -553,7 +553,7 @@ struct ProjectWindowRoot: View { // running per-window setup. State-restoration can spawn this window // before the main window has finished booting. while !appState.isInitialized { - try? await Task.sleep(nanoseconds: 50_000_000) + try? await Task.sleep(nanoseconds: 50000000) } windowState.isProjectWindow = true appState.setupChatBridge(chatBridge, for: windowState) diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index d3810dc..b438ec0 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -2,8 +2,8 @@ import Foundation import RxCodeCore import os -/// Fetches the marketplace catalog from Anthropic's GitHub repositories -/// and handles plugin installation/uninstallation via Claude Code CLI. +/// Fetches skill/plugin catalogs and keeps RxCode-owned install state. +/// Provider-specific config is materialized at launch time where possible. actor MarketplaceService { private let logger = Logger(subsystem: "com.claudework", category: "MarketplaceService") @@ -12,6 +12,7 @@ actor MarketplaceService { private var cachedCatalog: [MarketplacePlugin] = [] private var cacheDate: Date? private let cacheTTL: TimeInterval = 300 // 5 minutes + private let configURL = AppSupport.bundleScopedURL.appendingPathComponent("skills.json") /// Source repositories to scan. private static let sourceRepos: [(owner: String, repo: String, defaultCategory: String)] = [ @@ -88,6 +89,8 @@ actor MarketplaceService { let ownerInfo = json["owner"] as? [String: Any] let defaultAuthor = ownerInfo?["name"] as? String ?? owner + let marketplaceSource = MarketplaceSource(owner: owner, repo: repo) + return plugins.compactMap { entry -> MarketplacePlugin? in guard let name = entry["name"] as? String else { return nil } @@ -131,26 +134,33 @@ actor MarketplaceService { category: category, homepage: homepage, marketplace: marketplaceName, + marketplaceSource: marketplaceSource, sourceType: sourceType, skillPaths: skillPaths ) } } - // MARK: - Installation (via Claude Code CLI) + // MARK: - Installation - /// Retrieve the list of installed plugin names. + /// Retrieve installed plugin names from RxCode state, plus legacy Claude installs. func installedPluginNames() async -> Set { + var names = Set((try? loadConfig().plugins.map(\.name)) ?? []) + names.formUnion(await installedClaudePluginNames()) + return names + } + + private func installedClaudePluginNames() async -> Set { let (output, exitCode) = await runCLI(["plugin", "list", "--json"]) guard exitCode == 0, let data = output.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - return installedPluginNamesFromDisk() + return installedClaudePluginNamesFromDisk() } return Set(json.compactMap { $0["name"] as? String }) } - private func installedPluginNamesFromDisk() -> Set { + private func installedClaudePluginNamesFromDisk() -> Set { let home = FileManager.default.homeDirectoryForCurrentUser.path let fm = FileManager.default var names: Set = [] @@ -162,23 +172,148 @@ actor MarketplaceService { return names } - /// Install a plugin by running `claude plugin install @` + func importInstalledPlugins(catalog: [MarketplacePlugin], installedNames: Set) async { + do { + var config = try loadConfig() + var changed = false + let existingIds = Set(config.plugins.map(\.id)) + + for plugin in catalog where installedNames.contains(plugin.name) && !existingIds.contains(plugin.id) { + config.plugins.append(MarketplacePluginRecord( + name: plugin.name, + marketplace: plugin.marketplace, + summary: plugin.description, + category: plugin.category, + marketplaceSource: plugin.marketplaceSource + )) + changed = true + } + + if changed { + try saveConfig(config) + } + } catch { + logger.warning("Failed to import installed marketplace plugins: \(error.localizedDescription)") + } + } + + /// Install into RxCode-owned state and mirror to Claude Code when available. func installPlugin(_ plugin: MarketplacePlugin) async throws { + var config = try loadConfig() + let record = MarketplacePluginRecord( + name: plugin.name, + marketplace: plugin.marketplace, + summary: plugin.description, + category: plugin.category, + marketplaceSource: plugin.marketplaceSource + ) + + if let index = config.plugins.firstIndex(where: { $0.id == record.id }) { + config.plugins[index].isGloballyEnabled = true + config.plugins[index].enabledProviders = Set(AgentProvider.allCases) + config.plugins[index].marketplaceSource = plugin.marketplaceSource + config.plugins[index].summary = plugin.description + config.plugins[index].category = plugin.category + } else { + config.plugins.append(record) + } + try saveConfig(config) + let installArg = "\(plugin.name)@\(plugin.marketplace)" let (_, exitCode) = await runCLI(["plugin", "install", installArg]) - guard exitCode == 0 else { - throw MarketplaceError.installFailed(installArg) + if exitCode != 0 { + logger.warning("Claude plugin mirror install failed for \(installArg, privacy: .public)") } - logger.info("Installed plugin: \(plugin.name, privacy: .public) from \(plugin.marketplace, privacy: .public)") + logger.info("Installed skill: \(plugin.name, privacy: .public) from \(plugin.marketplace, privacy: .public)") } - /// Uninstall a plugin by running `claude plugin uninstall ` + /// Remove from RxCode-owned state and mirror to Claude Code when available. func uninstallPlugin(_ plugin: MarketplacePlugin) async throws { + var config = try loadConfig() + config.plugins.removeAll { $0.id == plugin.id || $0.name == plugin.name } + try saveConfig(config) + let (_, exitCode) = await runCLI(["plugin", "uninstall", plugin.name]) - guard exitCode == 0 else { - throw MarketplaceError.uninstallFailed(plugin.name) + if exitCode != 0 { + logger.warning("Claude plugin mirror uninstall failed for \(plugin.name, privacy: .public)") } - logger.info("Uninstalled plugin: \(plugin.name, privacy: .public)") + logger.info("Uninstalled skill: \(plugin.name, privacy: .public)") + } + + func codexConfigOverrides() async -> [String] { + do { + let config = try loadConfig() + let records = config.plugins + .filter { $0.isEnabled(for: .codex) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + var pairs: [String] = [] + var emittedMarketplaces: Set = [] + + for record in records { + if let source = record.marketplaceSource, + !emittedMarketplaces.contains(record.marketplace) { + emittedMarketplaces.insert(record.marketplace) + let marketplaceKey = "marketplaces.\(tomlKey(record.marketplace))" + pairs += ["-c", "\(marketplaceKey).source_type=\(tomlString("github"))"] + pairs += ["-c", "\(marketplaceKey).source=\(tomlString(source.codexSource))"] + } + + let pluginId = "\(record.name)@\(record.marketplace)" + pairs += ["-c", "plugins.\(tomlKey(pluginId)).enabled=true"] + } + if !pairs.isEmpty { + pairs = ["--enable", "plugins"] + pairs + } + return pairs + } catch { + logger.warning("Failed to build Codex skill overrides: \(error.localizedDescription)") + return [] + } + } + + func promptContext(for provider: AgentProvider) async -> String? { + do { + let records = try loadConfig().plugins + .filter { $0.isEnabled(for: provider) } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + guard !records.isEmpty else { return nil } + + var lines = ["Installed RxCode skills available for this session:"] + for record in records { + let detail = record.summary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if detail.isEmpty { + lines.append("- \(record.name) from \(record.marketplace)") + } else { + lines.append("- \(record.name) from \(record.marketplace): \(detail)") + } + } + return lines.joined(separator: "\n") + } catch { + logger.warning("Failed to build skill prompt context: \(error.localizedDescription)") + return nil + } + } + + // MARK: - RxCode Config + + private func loadConfig() throws -> MarketplacePluginConfiguration { + let fm = FileManager.default + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + guard fm.fileExists(atPath: configURL.path) else { + return MarketplacePluginConfiguration() + } + let data = try Data(contentsOf: configURL) + return try JSONDecoder().decode(MarketplacePluginConfiguration.self, from: data) + } + + private func saveConfig(_ config: MarketplacePluginConfiguration) throws { + let fm = FileManager.default + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: configURL, options: [.atomic]) } // MARK: - CLI Runner @@ -208,6 +343,21 @@ actor MarketplaceService { } } + private func tomlKey(_ key: String) -> String { + if key.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil { + return key + } + return tomlString(key) + } + + private func tomlString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } + // MARK: - Errors enum MarketplaceError: LocalizedError { diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index a8d56fe..c9f024b 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -6,6 +6,14 @@ import RxCodeCore import RxCodeSync import os.log +/// One per-activity Live Activity push token registered by a paired mobile. +/// The desktop targets `update`/`end` pushes at `token`, scoped to `sessionID`. +struct LiveActivityTokenRef: Codable, Sendable, Hashable { + var activityID: String + var sessionID: String + var token: String +} + /// One paired mobile device. Persisted to /// `~/Library/Application Support/RxCode/paired_devices.json`. struct PairedDevice: Codable, Identifiable, Sendable, Hashable { @@ -14,6 +22,12 @@ struct PairedDevice: Codable, Identifiable, Sendable, Hashable { var platform: String var apnsToken: String? var apnsEnvironment: String? + /// Device-wide Live Activity push-to-start token (iOS 17.2+). Lets the + /// desktop spawn a job Live Activity remotely. Optional for wire/forward + /// compatibility with paired-device files written before Live Activities. + var liveActivityStartToken: String? + /// Per-activity Live Activity update tokens, one per running job activity. + var liveActivityTokens: [LiveActivityTokenRef]? var pairedAt: Date var lastSeen: Date? @@ -77,6 +91,22 @@ final class MobileSyncService: ObservableObject { /// because AppState owns the storage layer and the streaming loop. private weak var appState: AnyObject? + // MARK: - Live Activity & widget state + + /// Resolves a project's display name for Live Activity attributes. Set by + /// `AppState` after initialization; `nil` before that. + var projectNameResolver: (@MainActor (UUID) -> String?)? + /// Supplies the current Claude Code / Codex 5-hour usage for the widget + /// background push. Set by `AppState`; `nil` before that. + var usageSnapshotProvider: (@MainActor () -> (cc: Double?, codex: Double?))? + + /// Session ids currently streaming — the live job count for the widget. + private var streamingSessionIDs: Set = [] + /// Per-job Live Activity bookkeeping, keyed by session id. + private var jobActivityState: [String: JobActivityState] = [:] + /// Last widget job count pushed, so a widget push only fires on a change. + private var lastWidgetJobCount: Int = -1 + init() { // Persisted relay URL or sensible default for self-host. let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") @@ -396,6 +426,7 @@ final class MobileSyncService: ObservableObject { ) await client.broadcast(.sessionUpdate(payload)) } + updateJobTracking(sessionID: sessionID, kind: kind, isStreaming: isStreaming, summary: summary) } /// Mirror the desktop's current `AskUserQuestion` queue to every paired @@ -413,6 +444,258 @@ final class MobileSyncService: ObservableObject { } } + // MARK: - Live Activity & widget push + + /// Fold a session update into the streaming-job set and the per-job Live + /// Activity state, then push any resulting Live Activity / widget changes. + /// Called for every `broadcastSessionUpdate`. + private func updateJobTracking( + sessionID: String, + kind: SessionUpdatePayload.Kind, + isStreaming: Bool?, + summary: RxCodeSync.SessionSummary? + ) { + let streaming: Bool? + switch kind { + case .streamingStarted: streaming = true + case .streamingFinished: streaming = false + default: streaming = isStreaming + } + if let streaming { + if streaming { streamingSessionIDs.insert(sessionID) } + else { streamingSessionIDs.remove(sessionID) } + } + // Summaries carry title/progress/todos — they drive the Live Activity. + if let summary { + reconcileJobActivity(summary: summary) + } + pushWidgetUpdateIfJobCountChanged() + } + + /// Start, update, or end a job's Live Activity based on the latest summary. + private func reconcileJobActivity(summary: RxCodeSync.SessionSummary) { + let sessionID = summary.id + let content = makeJobContent(from: summary) + if summary.isStreaming { + if var state = jobActivityState[sessionID] { + let changed = state.lastContent.signature != content.signature + state.lastContent = content + jobActivityState[sessionID] = state + if changed { + logger.info("[LiveActivity] reconcile decision=update session=\(sessionID, privacy: .public) todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public)") + sendLiveActivityUpdate(content, phase: "running") + } else { + logger.debug("[LiveActivity] reconcile decision=skip-unchanged session=\(sessionID, privacy: .public)") + } + } else { + jobActivityState[sessionID] = JobActivityState(lastContent: content) + logger.info("[LiveActivity] reconcile decision=start session=\(sessionID, privacy: .public)") + sendLiveActivityStart(content) + } + } else if jobActivityState[sessionID] != nil { + jobActivityState.removeValue(forKey: sessionID) + logger.info("[LiveActivity] reconcile decision=end session=\(sessionID, privacy: .public)") + sendLiveActivityEnd(content) + clearActivityTokens(sessionID: sessionID) + } else { + logger.debug("[LiveActivity] reconcile decision=ignore session=\(sessionID, privacy: .public) — not streaming and no active activity") + } + } + + private func makeJobContent(from summary: RxCodeSync.SessionSummary) -> JobContent { + JobContent( + sessionID: summary.id, + title: summary.title, + projectName: projectNameResolver?(summary.projectId) ?? "", + todoDone: summary.progress?.done ?? 0, + todoTotal: summary.progress?.total ?? 0, + currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm + ) + } + + /// Push a `start` Live Activity to every device with a push-to-start token. + private func sendLiveActivityStart(_ content: JobContent) { + let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } + guard !devices.isEmpty else { + logger.warning("[LiveActivity] start skipped session=\(content.sessionID, privacy: .public) — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public)); the mobile app must register one first") + return + } + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] start skipped session=\(content.sessionID, privacy: .public) — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "start", + "content-state": contentStateDict(content, phase: "running", at: now), + "attributes-type": "RxCodeJobActivityAttributes", + "attributes": [ + "sessionID": content.sessionID, + "projectName": content.projectName, + "title": content.title, + ], + "stale-date": Int(now.addingTimeInterval(3600).timeIntervalSince1970), + ]] + logger.info("[LiveActivity] start session=\(content.sessionID, privacy: .public) devices=\(devices.count, privacy: .public) project=\(content.projectName, privacy: .public) title=\(content.title, privacy: .public) phase=running todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public) step=\(content.currentStep ?? "", privacy: .public)") + for device in devices { + guard let token = device.liveActivityStartToken else { continue } + logger.info("[LiveActivity] start → posting push session=\(content.sessionID, privacy: .public) startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: token, pushType: "liveactivity", + apnsPayload: payload, collapseID: nil, device: device, pushURL: pushURL) + } + } + } + + private func sendLiveActivityUpdate(_ content: JobContent, phase: String) { + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "update", + "content-state": contentStateDict(content, phase: phase, at: now), + "stale-date": Int(now.addingTimeInterval(3600).timeIntervalSince1970), + ]] + pushToActivityTokens(sessionID: content.sessionID, payload: payload) + } + + private func sendLiveActivityEnd(_ content: JobContent) { + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "end", + "content-state": contentStateDict(content, phase: "done", at: now), + // Keep the "Done" state on screen briefly, then auto-dismiss. + "dismissal-date": Int(now.addingTimeInterval(300).timeIntervalSince1970), + ]] + logger.info("[LiveActivity] end session=\(content.sessionID, privacy: .public)") + pushToActivityTokens(sessionID: content.sessionID, payload: payload) + } + + /// Build the ActivityKit `content-state` dict. Field names mirror + /// `RxCodeJobActivityAttributes.ContentState` in the widget target. + private func contentStateDict(_ content: JobContent, phase: String, at date: Date) -> [String: Any] { + var dict: [String: Any] = [ + "phase": phase, + "todoDone": content.todoDone, + "todoTotal": content.todoTotal, + "updatedAt": date.timeIntervalSince1970, + ] + if let step = content.currentStep, !step.isEmpty { + dict["currentStep"] = step + } + return dict + } + + /// Push a Live Activity payload to every per-activity token bound to a job. + private func pushToActivityTokens(sessionID: String, payload: [String: Any]) { + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] update/end skipped session=\(sessionID, privacy: .public) — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + let collapseID = "rxcode-la-\(sessionID.prefix(40))" + var matched = 0 + for device in pairedDevices { + for ref in (device.liveActivityTokens ?? []) where ref.sessionID == sessionID { + matched += 1 + logger.info("[LiveActivity] push → activity token session=\(sessionID, privacy: .public) activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: ref.token, pushType: "liveactivity", + apnsPayload: payload, collapseID: collapseID, + device: device, pushURL: pushURL) + } + } + } + if matched == 0 { + logger.warning("[LiveActivity] update/end has no registered per-activity token session=\(sessionID, privacy: .public) — the mobile never reported one, so push-to-start likely failed to spawn an Activity on the device") + } + } + + /// Forget the per-activity tokens of a finished job. + private func clearActivityTokens(sessionID: String) { + var changed = false + for i in pairedDevices.indices { + guard let refs = pairedDevices[i].liveActivityTokens, + refs.contains(where: { $0.sessionID == sessionID }) else { continue } + pairedDevices[i].liveActivityTokens = refs.filter { $0.sessionID != sessionID } + changed = true + } + if changed { savePairedDevices() } + } + + private func pushWidgetUpdateIfJobCountChanged() { + guard streamingSessionIDs.count != lastWidgetJobCount else { return } + pushWidgetUpdate() + } + + /// Push the current ongoing-job count and agent usage to every paired + /// device as a silent background notification, refreshing the home-screen + /// widget. Also called by `AppState` when rate-limit usage refreshes. + func pushWidgetUpdate() { + let jobCount = streamingSessionIDs.count + lastWidgetJobCount = jobCount + let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } + guard !devices.isEmpty, let pushURL = Self.pushEndpointURL(from: relayURL) else { return } + let usage = usageSnapshotProvider?() + var widget: [String: Any] = [ + "jobs": jobCount, + "updatedAt": Date().timeIntervalSince1970, + ] + if let cc = usage?.cc { widget["cc"] = cc } + if let codex = usage?.codex { widget["codex"] = codex } + let payload: [String: Any] = ["aps": ["content-available": 1], "widget": widget] + for device in devices { + guard let token = device.apnsToken else { continue } + Task { + await postRawPush(deviceToken: token, pushType: "background", + apnsPayload: payload, collapseID: "rxcode-widget", + device: device, pushURL: pushURL) + } + } + } + + /// POST a raw (Live Activity or background) push to the relay `/push` + /// endpoint. Failures are logged and swallowed — these are best-effort. + private func postRawPush( + deviceToken: String, + pushType: String, + apnsPayload: [String: Any], + collapseID: String?, + device: PairedDevice, + pushURL: URL + ) async { + var bodyDict: [String: Any] = [ + "device_token": deviceToken, + "push_type": pushType, + "apns_payload": apnsPayload, + ] + if let collapseID { bodyDict["collapse_id"] = collapseID } + do { + let httpBody = try JSONSerialization.data(withJSONObject: bodyDict) + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { return } + guard (200..<300).contains(http.statusCode) else { + logger.error("[Push] \(pushType, privacy: .public) relay rejected status=\(http.statusCode, privacy: .public) body=\(Self.responseBodyString(data), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + return + } + if let pushResponse = try? JSONDecoder().decode(APNsPushResponse.self, from: data) { + if (200..<300).contains(pushResponse.statusCode) { + logger.info("[Push] \(pushType, privacy: .public) accepted apnsStatus=\(pushResponse.statusCode, privacy: .public) apnsID=\(pushResponse.apnsID ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } else { + logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } else { + logger.info("[Push] \(pushType, privacy: .public) relay accepted httpStatus=\(http.statusCode, privacy: .public) (no APNs detail in response) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } catch { + logger.error("[Push] \(pushType, privacy: .public) failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + // MARK: - Event dispatch private func handle(event: RelayClient.Event) { @@ -452,6 +735,35 @@ final class MobileSyncService: ObservableObject { } else { logger.warning("[APNs] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") } + case .liveActivityToken(let t): + guard let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) else { + logger.warning("[LiveActivity] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + return + } + if let startToken = t.pushToStartTokenHex, !startToken.isEmpty { + pairedDevices[idx].liveActivityStartToken = startToken + logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } + if let activityToken = t.activityTokenHex, !activityToken.isEmpty, + let activityID = t.activityID { + let ref = LiveActivityTokenRef( + activityID: activityID, + sessionID: t.sessionID ?? "", + token: activityToken + ) + var refs = pairedDevices[idx].liveActivityTokens ?? [] + refs.removeAll { $0.activityID == activityID } + refs.append(ref) + pairedDevices[idx].liveActivityTokens = refs + logger.info("[LiveActivity] activity token registered activity=\(activityID, privacy: .public) session=\(ref.sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + // Push the latest known state straight away so a freshly + // started activity isn't left blank until the next change. + if !ref.sessionID.isEmpty, let st = jobActivityState[ref.sessionID] { + sendLiveActivityUpdate(st.lastContent, phase: "running") + } + } + pairedDevices[idx].lastSeen = .now + savePairedDevices() case .requestSnapshot(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } logger.info("[MobileSync] snapshot requested by mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) activeSession=\(req.activeSessionID ?? "", privacy: .public)") @@ -744,6 +1056,25 @@ private struct APNsPushResponse: Codable { } } +/// Latest content the desktop knows for one job's Live Activity. +private struct JobContent { + var sessionID: String + var title: String + var projectName: String + var todoDone: Int + var todoTotal: Int + var currentStep: String? + + /// Identifies a distinct rendered state, so an update only pushes on a + /// real change rather than on every session event. + var signature: String { "\(todoDone)/\(todoTotal)|\(currentStep ?? "")" } +} + +/// Per-job Live Activity bookkeeping, keyed by session id in `MobileSyncService`. +private struct JobActivityState { + var lastContent: JobContent +} + extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") diff --git a/RxCode/Views/Chat/SkillMarketView.swift b/RxCode/Views/Chat/SkillMarketView.swift index 7ba9b5c..77fc5fc 100644 --- a/RxCode/Views/Chat/SkillMarketView.swift +++ b/RxCode/Views/Chat/SkillMarketView.swift @@ -4,7 +4,6 @@ import RxCodeCore /// Skill marketplace panel — displayed as an overlay or embedded in a settings tab. struct SkillMarketView: View { @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState @Environment(\.dismiss) private var dismiss @State private var searchText = "" @State private var selectedFilter = "All" @@ -30,11 +29,7 @@ struct SkillMarketView: View { } .sheet(item: $selectedPlugin) { plugin in PluginDetailView( - plugin: plugin, - isInstalled: appState.marketplaceInstalledNames.contains(plugin.name), - installStatus: appState.marketplacePluginStates[plugin.id] ?? .notInstalled, - onInstall: {}, - onUninstall: {} + plugin: plugin ) .focusable(false) } @@ -337,15 +332,17 @@ struct PluginCard: View { struct PluginDetailView: View { let plugin: MarketplacePlugin - let isInstalled: Bool - let installStatus: PluginInstallStatus - let onInstall: () -> Void - let onUninstall: () -> Void @Environment(\.dismiss) private var dismiss @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @State private var terminalState: InteractiveTerminalState? + + private var isInstalled: Bool { + appState.marketplaceInstalledNames.contains(plugin.name) + } + + private var installStatus: PluginInstallStatus { + appState.marketplacePluginStates[plugin.id] ?? .notInstalled + } var body: some View { VStack(spacing: 0) { @@ -422,11 +419,11 @@ struct PluginDetailView: View { // Install command VStack(alignment: .leading, spacing: 6) { - Text("Install Command") + Text("Agent Availability") .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - Text(plugin.installCommand) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + Text("Installed skills are managed by RxCode and enabled for Claude Code, Codex, and ACP agents where supported.") + .font(.system(size: ClaudeTheme.size(12))) .foregroundStyle(.secondary) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) @@ -438,12 +435,6 @@ struct PluginDetailView: View { } } .frame(width: 620, height: 500) - .sheet(item: $terminalState) { terminal in - InteractiveTerminalPopup(state: terminal) - .onDisappear { - Task { await appState.loadMarketplace() } - } - } } @ViewBuilder @@ -462,13 +453,7 @@ struct PluginDetailView: View { @ViewBuilder private var removeButton: some View { Button("Remove") { - terminalState = InteractiveTerminalState( - title: "Uninstall \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin uninstall \(plugin.name)", - reportToChat: false - ) + Task { await appState.uninstallMarketplacePlugin(plugin) } } .font(.system(size: ClaudeTheme.size(12), weight: .medium)) .foregroundStyle(Color.red) @@ -495,24 +480,12 @@ struct PluginDetailView: View { removeButton case .failed: Button("Retry") { - terminalState = InteractiveTerminalState( - title: "Install \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin install \(plugin.name)@\(plugin.marketplace)", - reportToChat: false - ) + Task { await appState.installMarketplacePlugin(plugin) } } .buttonStyle(.borderedProminent) default: Button("Install") { - terminalState = InteractiveTerminalState( - title: "Install \(plugin.name)", - executable: "/bin/zsh", - arguments: ["-il"], - initialCommand: "claude plugin install \(plugin.name)@\(plugin.marketplace)", - reportToChat: false - ) + Task { await appState.installMarketplacePlugin(plugin) } } .buttonStyle(.borderedProminent) } diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 38e516e..977f677 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -41,23 +41,29 @@ struct SettingsView: View { } .tag(3) + SkillMarketView(isEmbedded: true) + .tabItem { + Label("Skill Marketplace", systemImage: "brain.head.profile") + } + .tag(4) + MCPSettingsTab() .tabItem { Label("MCP", systemImage: "puzzlepiece.extension") } - .tag(4) + .tag(5) ACPClientSettingsTab() .tabItem { Label("ACP Clients", systemImage: "link.circle") } - .tag(5) + .tag(6) MobileSettingsTab() .tabItem { Label("Mobile", systemImage: "iphone.gen3") } - .tag(6) + .tag(7) } .frame(width: 680, height: 620) .focusable(false) @@ -85,7 +91,6 @@ struct GeneralSettingsTab: View { @Environment(AppState.self) private var appState @Binding var showUserManual: Bool @Binding var showOnboarding: Bool - @State private var showSkillMarket = false @State private var showThemePicker = false @AppStorage("showMenuBarExtra") private var showMenuBarExtra: Bool = true @@ -104,7 +109,6 @@ struct GeneralSettingsTab: View { searchIndexSection Divider() VStack(alignment: .leading, spacing: 8) { - skillMarketSection onboardingSection helpSection sourceCodeSection @@ -319,44 +323,6 @@ struct GeneralSettingsTab: View { } } - // MARK: - Skill Market Section - - private var skillMarketSection: some View { - Button { - showSkillMarket = true - } label: { - HStack(spacing: 10) { - Image(systemName: "brain.head.profile") - .font(.system(size: ClaudeTheme.size(14))) - .frame(width: 20) - VStack(alignment: .leading, spacing: 1) { - Text("Skill Marketplace") - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(.primary) - Text("Browse and manage Claude Code skills") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "arrow.up.right.square") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .sheet(isPresented: $showSkillMarket) { - SkillMarketView(isEmbedded: false) - } - } - // MARK: - Source Code Section private var sourceCodeSection: some View { diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index 8e708fa..fa3bc73 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -539,11 +539,11 @@ enum ManualTopic: String, CaseIterable, Identifiable { [ ManualSection( title: "Skill Marketplace", - body: "Click the brain icon (🧠) in the toolbar to browse the MCP plugin catalog published on Anthropic's GitHub. Plugins can be filtered by category or searched by name, description, or author." + body: "Open Settings and select Skill Marketplace to browse agent skills from configured catalogs. Skills can be filtered by category or searched by name, description, or author." ), ManualSection( - title: "Installing Plugins", - body: "Click a plugin to view its details, then press Install. An interactive terminal popup opens and runs the install command automatically.", + title: "Installing Skills", + body: "Click a skill to view its details, then press Install. RxCode stores installed skills in its own settings and enables them for supported coding agents.", items: [ KeyValueItem(key: "clock", value: "Not installed", symbolName: "clock", symbolColor: .secondary), KeyValueItem(key: "arrow.down", value: "Installing…", symbolName: "arrow.down.circle", symbolColor: .accentColor), diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index 6bb8523..940ebdb 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -56,6 +56,35 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent logger.error("[APNs] registration failed: \(error.localizedDescription, privacy: .public)") } + /// Handles a silent background push carrying a fresh home-screen widget + /// snapshot. The desktop sends these (`push_type=background`) whenever the + /// ongoing-job count or agent usage changes; the payload is plaintext + /// because WidgetKit data is low-sensitivity and not user-facing text. + func application(_ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async + -> UIBackgroundFetchResult { + guard let widget = userInfo["widget"] as? [String: Any] else { + return .noData + } + // Merge into the existing snapshot so a job-count-only push doesn't + // wipe the usage figures (and vice versa). + var snapshot = RxCodeWidgetStore.load() + if let jobs = (widget["jobs"] as? NSNumber)?.intValue { + snapshot.jobCount = jobs + } + if let cc = (widget["cc"] as? NSNumber)?.doubleValue { + snapshot.ccUsagePercent = cc + } + if let codex = (widget["codex"] as? NSNumber)?.doubleValue { + snapshot.codexUsagePercent = codex + } + snapshot.updatedAt = (widget["updatedAt"] as? NSNumber)?.doubleValue + ?? Date().timeIntervalSince1970 + RxCodeWidgetStore.save(snapshot) + logger.info("[Widget] background push applied jobs=\(snapshot.jobCount, privacy: .public)") + return .newData + } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { let content = notification.request.content diff --git a/RxCodeMobile/Info.plist b/RxCodeMobile/Info.plist index ea09a11..5e68e6b 100644 --- a/RxCodeMobile/Info.plist +++ b/RxCodeMobile/Info.plist @@ -4,6 +4,10 @@ ITSAppUsesNonExemptEncryption + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + NSCameraUsageDescription Scan a QR code shown by RxCode on your Mac to pair this device. NSLocalNetworkUsageDescription diff --git a/RxCodeMobile/RxCodeMobile.entitlements b/RxCodeMobile/RxCodeMobile.entitlements index 3a4a5a4..e7b7ab6 100644 --- a/RxCodeMobile/RxCodeMobile.entitlements +++ b/RxCodeMobile/RxCodeMobile.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.security.application-groups + + group.app.rxlab.rxcodemobile + com.apple.developer.associated-domains applinks:code.rxlab.app diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index e39272e..960ad57 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -7,6 +7,7 @@ struct RxCodeMobileApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var state = MobileAppState() @State private var windowState = WindowState() + @State private var liveActivityCoordinator = MobileLiveActivityCoordinator() @Environment(\.scenePhase) private var scenePhase init() { @@ -23,6 +24,7 @@ struct RxCodeMobileApp: App { .environment(windowState) .onAppear { appDelegate.mobileState = state + liveActivityCoordinator.bind(state: state) state.start() } .onOpenURL { url in diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 05301e8..f255c51 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -870,6 +870,37 @@ final class MobileAppState: ObservableObject { Task { await reportAPNsTokenIfPending() } } + // MARK: - Live Activity & widget + + /// Forward a Live Activity push token (a push-to-start token, a per-activity + /// update token, or both) to every paired desktop so it can drive the job + /// Live Activity over APNs. Called by `MobileLiveActivityCoordinator`. + func sendLiveActivityToken(_ payload: LiveActivityTokenPayload) async { + guard !pairedDesktops.isEmpty else { return } + for desktop in pairedDesktops { + do { + try await client.send(.liveActivityToken(payload), toHex: desktop.pubkeyHex) + logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[LiveActivity] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Recompute the home-screen widget snapshot from the current mirrored + /// state and persist it into the shared App Group container. Cheap to call + /// often — `RxCodeWidgetStore` reloads WidgetKit timelines on a real change. + func refreshWidgetData() { + let jobCount = sessions.filter { $0.isStreaming }.count + let snapshot = RxCodeWidgetData( + jobCount: jobCount, + ccUsagePercent: desktopUsage?.claudeCode?.fiveHourPercent, + codexUsagePercent: desktopUsage?.codex?.fiveHourPercent, + updatedAt: Date().timeIntervalSince1970 + ) + RxCodeWidgetStore.save(snapshot) + } + /// Routes a tapped APNs notification to its thread. Called by `AppDelegate`'s /// `didReceive` handler; `RootView` observes `pendingDeepLink` and navigates. func openThreadFromNotification(sessionID: String, projectID: UUID?) { @@ -1047,12 +1078,14 @@ final class MobileAppState: ObservableObject { } activeSessionID = active } + refreshWidgetData() case .moreMessages(let page): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } applyMoreMessages(page) case .sessionUpdate(let update): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "session_update") else { return } applySessionUpdate(update) + refreshWidgetData() case .permissionRequest(let req): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "permission_request") else { return } pendingPermission = req diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift new file mode 100644 index 0000000..106072d --- /dev/null +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -0,0 +1,150 @@ +// +// MobileLiveActivityCoordinator.swift +// RxCodeMobile +// +// Owns the iOS side of the job Live Activity push lifecycle. +// +// The paired desktop is what actually starts, updates, and ends Live +// Activities — it does so over APNs (see `MobileSyncService`). This +// coordinator's only job is to harvest the two kinds of ActivityKit push +// token and forward them to the desktop: +// +// 1. `Activity.pushToStartTokenUpdates` — the device-wide push-to-start +// token (iOS 17.2+) that lets the desktop spawn an activity remotely +// when a new job begins. +// 2. Each activity's `pushTokenUpdates` — the per-activity update token the +// desktop targets for `update` and `end` pushes. +// + +#if os(iOS) +import ActivityKit +import Combine +import Foundation +import RxCodeSync +import os.log + +@MainActor +final class MobileLiveActivityCoordinator { + private weak var state: MobileAppState? + private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "LiveActivity") + + /// Latest device-wide push-to-start token. + private var startTokenHex: String? + /// Per-activity update tokens, keyed by `Activity.id`. + private var activityTokens: [String: (sessionID: String, tokenHex: String)] = [:] + private var observationStarted = false + private var cancellables = Set() + + /// Wire the coordinator to the app state. Safe to call once; later calls + /// are ignored. Starts ActivityKit token observation and re-reports tokens + /// whenever the relay reconnects. + func bind(state: MobileAppState) { + self.state = state + guard !observationStarted else { return } + observationStarted = true + startObserving() + state.$connectionState + .receive(on: DispatchQueue.main) + .sink { [weak self] connectionState in + if case .connected = connectionState { + self?.resendTokens() + } + } + .store(in: &cancellables) + } + + private func startObserving() { + guard #available(iOS 16.1, *) else { + logger.info("[LiveActivity] iOS < 16.1 — Live Activities unavailable") + return + } + // ActivityKit silently drops a push-to-start when Live Activities are + // disabled for the app — log the authorization state so a missing + // activity can be told apart from a malformed push. + let authInfo = ActivityAuthorizationInfo() + if #available(iOS 17.2, *) { + logger.info("[LiveActivity] start observing — activitiesEnabled=\(authInfo.areActivitiesEnabled, privacy: .public) frequentPushesEnabled=\(authInfo.frequentPushesEnabled, privacy: .public)") + } else { + logger.info("[LiveActivity] start observing — activitiesEnabled=\(authInfo.areActivitiesEnabled, privacy: .public)") + } + if !authInfo.areActivitiesEnabled { + logger.warning("[LiveActivity] Live Activities are DISABLED in Settings — iOS will drop the desktop's push-to-start; enable them under Settings ▸ RxCode") + } + // Push-to-start token: device-wide, iOS 17.2+. + if #available(iOS 17.2, *) { + Task { [weak self] in + for await tokenData in Activity.pushToStartTokenUpdates { + let hex = tokenData.map { String(format: "%02x", $0) }.joined() + self?.handleStartToken(hex) + } + } + } else { + logger.warning("[LiveActivity] iOS < 17.2 — push-to-start unavailable; the desktop cannot spawn an activity remotely") + } + // Re-attach to activities already running (e.g. after a relaunch), + // then pick up future ones as ActivityKit reports them. + let existing = Activity.activities + logger.info("[LiveActivity] re-attaching to \(existing.count, privacy: .public) existing activity(ies)") + for activity in existing { + observe(activity) + } + Task { [weak self] in + for await activity in Activity.activityUpdates { + self?.logger.info("[LiveActivity] activityUpdates reported activity id=\(activity.id, privacy: .public) — push-to-start spawned an activity") + self?.observe(activity) + } + } + } + + @available(iOS 16.1, *) + private func observe(_ activity: Activity) { + logger.info("[LiveActivity] observing activity id=\(activity.id, privacy: .public) session=\(activity.attributes.sessionID, privacy: .public)") + Task { [weak self] in + for await tokenData in activity.pushTokenUpdates { + let hex = tokenData.map { String(format: "%02x", $0) }.joined() + self?.handleActivityToken(activity: activity, hex: hex) + } + } + } + + private func handleStartToken(_ hex: String) { + guard startTokenHex != hex else { return } + startTokenHex = hex + logger.info("[LiveActivity] push-to-start token \(hex.prefix(8), privacy: .public)…") + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload(pushToStartTokenHex: hex)) + } + } + + @available(iOS 16.1, *) + private func handleActivityToken(activity: Activity, hex: String) { + let sessionID = activity.attributes.sessionID + guard activityTokens[activity.id]?.tokenHex != hex else { return } + activityTokens[activity.id] = (sessionID, hex) + logger.info("[LiveActivity] activity token id=\(activity.id, privacy: .public) session=\(sessionID, privacy: .public) \(hex.prefix(8), privacy: .public)…") + let activityID = activity.id + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityTokenHex: hex, activityID: activityID, sessionID: sessionID + )) + } + } + + /// Re-send every token we hold. Called when the relay reconnects so the + /// desktop's per-device token registry survives a disconnect. + private func resendTokens() { + if let startTokenHex { + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload(pushToStartTokenHex: startTokenHex)) + } + } + for (activityID, entry) in activityTokens { + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityTokenHex: entry.tokenHex, activityID: activityID, sessionID: entry.sessionID + )) + } + } + } +} +#endif diff --git a/RxCodeMobile/Views/QRScannerView.swift b/RxCodeMobile/Views/QRScannerView.swift index 151829c..22cb71b 100644 --- a/RxCodeMobile/Views/QRScannerView.swift +++ b/RxCodeMobile/Views/QRScannerView.swift @@ -21,6 +21,8 @@ struct QRScannerView: UIViewControllerRepresentable { var onResult: ((String) -> Void)? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? + private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + private var rotationObservation: NSKeyValueObservation? override func viewDidLoad() { super.viewDidLoad() @@ -77,12 +79,34 @@ struct QRScannerView: UIViewControllerRepresentable { preview.frame = view.layer.bounds view.layer.addSublayer(preview) previewLayer = preview + + // Keep the preview upright as the device rotates (notably on iPad, + // which is not locked to portrait like the iPhone layout). + let coordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: preview) + rotationCoordinator = coordinator + applyPreviewRotation() + rotationObservation = coordinator.observe( + \.videoRotationAngleForHorizonLevelPreview, + options: [.new] + ) { [weak self] _, _ in + DispatchQueue.main.async { self?.applyPreviewRotation() } + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() scannerLogger.info("capture session started") } } + private func applyPreviewRotation() { + guard let coordinator = rotationCoordinator, + let connection = previewLayer?.connection else { return } + let angle = coordinator.videoRotationAngleForHorizonLevelPreview + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() previewLayer?.frame = view.layer.bounds diff --git a/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/Contents.json b/RxCodeWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RxCodeWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RxCodeWidget/Info.plist b/RxCodeWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/RxCodeWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift new file mode 100644 index 0000000..bc4f0e3 --- /dev/null +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -0,0 +1,61 @@ +// +// RxCodeJobActivity.swift +// RxCode +// +// Shared Live Activity attributes for an RxCode "job" — one in-progress chat +// session (agent run). This file is compiled into BOTH the RxCodeMobile app +// target (which starts and observes the activity) and the RxCodeWidget +// extension (which renders it). +// +// The desktop builds the APNs `content-state` JSON by hand, so the field +// names and types here are a wire contract: keep them in sync with +// `MobileSyncService` on macOS. Only plain JSON-friendly types are used +// (String / Int / Double / String-backed enum) so ActivityKit can decode a +// pushed content-state without a custom strategy. +// + +#if os(iOS) +import ActivityKit +import Foundation + +/// Live Activity descriptor for a single ongoing RxCode job. +struct RxCodeJobActivityAttributes: ActivityAttributes { + /// The mutable part, refreshed via APNs `update` pushes from the desktop. + struct ContentState: Codable, Hashable { + /// Lifecycle phase of the job. + enum Phase: String, Codable, Hashable { + /// The agent is still working. + case running + /// The agent finished — the activity shows "Done" before dismissal. + case done + } + + var phase: Phase + /// Completed todo count. Zero/`todoTotal == 0` means no todo list. + var todoDone: Int + /// Total todo count; zero when the job has no todo list yet. + var todoTotal: Int + /// Active-form label of the in-progress todo (e.g. "Running tests"). + /// `nil` when there is no todo list or nothing is in progress. + var currentStep: String? + /// Desktop-side update time, unix seconds. A `Double` rather than + /// `Date` so a pushed content-state decodes without a date strategy. + var updatedAt: Double + + /// Fractional progress 0...1; zero when the job has no todo list. + var fractionComplete: Double { + guard todoTotal > 0 else { return 0 } + return min(1, max(0, Double(todoDone) / Double(todoTotal))) + } + + var hasTodos: Bool { todoTotal > 0 } + } + + /// Chat-session id the job belongs to. Stable for the activity's lifetime. + var sessionID: String + /// Project display name, shown as the subtitle. + var projectName: String + /// Thread title. + var title: String +} +#endif diff --git a/RxCodeWidget/RxCodeWidget.entitlements b/RxCodeWidget/RxCodeWidget.entitlements new file mode 100644 index 0000000..28721cf --- /dev/null +++ b/RxCodeWidget/RxCodeWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.rxlab.rxcodemobile + + + diff --git a/RxCodeWidget/RxCodeWidget.swift b/RxCodeWidget/RxCodeWidget.swift new file mode 100644 index 0000000..a872431 --- /dev/null +++ b/RxCodeWidget/RxCodeWidget.swift @@ -0,0 +1,190 @@ +// +// RxCodeWidget.swift +// RxCodeWidget +// +// Home-screen widget mirroring the desktop menubar: the number of ongoing +// jobs plus Claude Code and Codex rate-limit usage. Data is written by the +// iOS app into the shared App Group and refreshed via background APNs pushes. +// + +import WidgetKit +import SwiftUI + +/// RxCode terracotta accent (#D97757). +private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) + +// MARK: - Timeline + +struct RxCodeWidgetEntry: TimelineEntry { + let date: Date + let data: RxCodeWidgetData +} + +struct RxCodeWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> RxCodeWidgetEntry { + RxCodeWidgetEntry( + date: Date(), + data: RxCodeWidgetData(jobCount: 2, ccUsagePercent: 45, codexUsagePercent: 12, updatedAt: 0) + ) + } + + func getSnapshot(in context: Context, completion: @escaping (RxCodeWidgetEntry) -> Void) { + completion(RxCodeWidgetEntry(date: Date(), data: RxCodeWidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = RxCodeWidgetEntry(date: Date(), data: RxCodeWidgetStore.load()) + // The app pushes fresh data via background APNs; this hourly refresh is + // just a fallback so the widget never goes fully stale. + let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) + ?? Date().addingTimeInterval(3600) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Views + +struct RxCodeWidgetEntryView: View { + @Environment(\.widgetFamily) private var family + var entry: RxCodeWidgetProvider.Entry + + var body: some View { + switch family { + case .systemMedium: + mediumBody + default: + smallBody + } + } + + private var smallBody: some View { + VStack(alignment: .leading, spacing: 10) { + jobsHeader + Spacer(minLength: 0) + usageRow(label: "CC", percent: entry.data.ccUsagePercent) + usageRow(label: "CX", percent: entry.data.codexUsagePercent) + } + } + + private var mediumBody: some View { + HStack(alignment: .center, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + jobsHeader + Spacer(minLength: 0) + Text(updatedLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Divider() + VStack(alignment: .leading, spacing: 12) { + usageRow(label: "Claude Code", percent: entry.data.ccUsagePercent) + usageRow(label: "Codex", percent: entry.data.codexUsagePercent) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var jobsHeader: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Image(systemName: "hammer.fill") + .font(.caption2) + .foregroundStyle(rxAccent) + Text("RxCode") + .font(.caption2.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(entry.data.jobCount)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundStyle(entry.data.jobCount > 0 ? rxAccent : .primary) + Text(entry.data.jobCount == 1 ? "job" : "jobs") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private func usageRow(label: String, percent: Double?) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + Text(percent.map { "\(Int($0.rounded()))%" } ?? "—") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(percent == nil ? .secondary : .primary) + } + UsageBar(percent: percent) + } + } + + private var updatedLabel: String { + guard entry.data.updatedAt > 0 else { return "No data yet" } + let date = Date(timeIntervalSince1970: entry.data.updatedAt) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return "Updated \(formatter.localizedString(for: date, relativeTo: Date()))" + } +} + +/// A thin capsule usage bar that tints toward red as utilization climbs. +private struct UsageBar: View { + let percent: Double? + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.22)) + Capsule() + .fill(tint) + .frame(width: geo.size.width * fraction) + } + } + .frame(height: 5) + } + + private var fraction: Double { + guard let percent else { return 0 } + return min(1, max(0, percent / 100)) + } + + private var tint: Color { + guard let percent else { return .secondary } + if percent >= 90 { return .red } + if percent >= 70 { return .orange } + return rxAccent + } +} + +// MARK: - Widget + +struct RxCodeWidget: Widget { + let kind: String = "RxCodeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: RxCodeWidgetProvider()) { entry in + RxCodeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("RxCode Jobs") + .description("Ongoing jobs and Claude Code / Codex usage.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +#Preview(as: .systemSmall) { + RxCodeWidget() +} timeline: { + RxCodeWidgetEntry(date: .now, data: RxCodeWidgetData(jobCount: 3, ccUsagePercent: 64, codexUsagePercent: 22, updatedAt: Date().timeIntervalSince1970)) + RxCodeWidgetEntry(date: .now, data: .empty) +} + +#Preview(as: .systemMedium) { + RxCodeWidget() +} timeline: { + RxCodeWidgetEntry(date: .now, data: RxCodeWidgetData(jobCount: 1, ccUsagePercent: 91, codexUsagePercent: nil, updatedAt: Date().timeIntervalSince1970)) +} diff --git a/RxCodeWidget/RxCodeWidgetBundle.swift b/RxCodeWidget/RxCodeWidgetBundle.swift new file mode 100644 index 0000000..9c2af22 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetBundle.swift @@ -0,0 +1,17 @@ +// +// RxCodeWidgetBundle.swift +// RxCodeWidget +// +// Created by Qiwei Li on 5/20/26. +// + +import WidgetKit +import SwiftUI + +@main +struct RxCodeWidgetBundle: WidgetBundle { + var body: some Widget { + RxCodeWidget() + RxCodeWidgetLiveActivity() + } +} diff --git a/RxCodeWidget/RxCodeWidgetData.swift b/RxCodeWidget/RxCodeWidgetData.swift new file mode 100644 index 0000000..a8ff7c2 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetData.swift @@ -0,0 +1,64 @@ +// +// RxCodeWidgetData.swift +// RxCode +// +// Shared snapshot for the RxCode home-screen widget. Compiled into BOTH the +// RxCodeMobile app target (which writes it) and the RxCodeWidget extension +// (which reads it). The data crosses the process boundary through the +// App Group container, and the app nudges WidgetKit to reload after a write. +// + +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +/// What the RxCode home-screen widget renders: the number of ongoing jobs and +/// the agents' rate-limit usage, mirrored from the desktop menubar. +struct RxCodeWidgetData: Codable, Equatable { + /// Number of in-progress jobs (streaming chat sessions). + var jobCount: Int + /// Claude Code 5-hour utilization, 0...100. `nil` when not signed in. + var ccUsagePercent: Double? + /// Codex 5-hour utilization, 0...100. `nil` when not signed in. + var codexUsagePercent: Double? + /// When the desktop produced this snapshot, unix seconds. + var updatedAt: Double + + static let empty = RxCodeWidgetData( + jobCount: 0, ccUsagePercent: nil, codexUsagePercent: nil, updatedAt: 0 + ) +} + +/// Reads and writes `RxCodeWidgetData` in the App Group shared by the app and +/// the widget extension. +enum RxCodeWidgetStore { + /// App Group identifier — must match the `com.apple.security.application-groups` + /// entitlement on both the app and the widget targets. + static let appGroupID = "group.app.rxlab.rxcodemobile" + + private static let snapshotKey = "widget.snapshot" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + /// Current snapshot, or `.empty` when nothing has been written yet. + static func load() -> RxCodeWidgetData { + guard let data = defaults?.data(forKey: snapshotKey), + let decoded = try? JSONDecoder().decode(RxCodeWidgetData.self, from: data) + else { return .empty } + return decoded + } + + /// Persist a fresh snapshot and ask WidgetKit to reload the timeline. + /// Called from the app (foreground sync and background widget push). + static func save(_ snapshot: RxCodeWidgetData) { + guard let defaults, + let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: snapshotKey) + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } +} diff --git a/RxCodeWidget/RxCodeWidgetLiveActivity.swift b/RxCodeWidget/RxCodeWidgetLiveActivity.swift new file mode 100644 index 0000000..75004c8 --- /dev/null +++ b/RxCodeWidget/RxCodeWidgetLiveActivity.swift @@ -0,0 +1,198 @@ +// +// RxCodeWidgetLiveActivity.swift +// RxCodeWidget +// +// Live Activity for an RxCode ongoing job. Mirrors the desktop menubar: +// an "Ongoing job" title plus todo-list progress while the agent works, +// and a "Done" state when it finishes. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +/// RxCode terracotta accent (#D97757). +private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) + +struct RxCodeWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RxCodeJobActivityAttributes.self) { context in + // Lock screen / banner presentation. + JobLockScreenView(context: context) + .activityBackgroundTint(Color.black.opacity(0.55)) + .activitySystemActionForegroundColor(rxAccent) + + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + .font(.title3) + } + DynamicIslandExpandedRegion(.trailing) { + Text(trailingLabel(for: context.state)) + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + JobExpandedBottomView(context: context) + } + } compactLeading: { + Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + } compactTrailing: { + Text(trailingLabel(for: context.state)) + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + } minimal: { + Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + } + .widgetURL(URL(string: "rxcode://thread/\(context.attributes.sessionID)")) + .keylineTint(rxAccent) + } + } + + /// Compact label: a "✓" once done, "3/5" with a todo list, "•••" otherwise. + private func trailingLabel(for state: RxCodeJobActivityAttributes.ContentState) -> String { + if state.phase == .done { return "Done" } + if state.hasTodos { return "\(state.todoDone)/\(state.todoTotal)" } + return "•••" + } +} + +// MARK: - Lock screen + +private struct JobLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") + .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + Text(context.state.phase == .done ? "Job done" : "Ongoing job") + .font(.caption.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + if context.state.phase == .running, context.state.hasTodos { + Text("\(context.state.todoDone)/\(context.state.todoTotal)") + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(rxAccent) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.title) + .font(.headline) + .lineLimit(1) + if !context.attributes.projectName.isEmpty { + Text(context.attributes.projectName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + JobProgressView(state: context.state) + + if context.state.phase == .running, + let step = context.state.currentStep, !step.isEmpty { + Text(step) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(16) + } +} + +// MARK: - Dynamic Island expanded bottom + +private struct JobExpandedBottomView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if !context.attributes.projectName.isEmpty { + Text(context.attributes.projectName) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + JobProgressView(state: context.state) + if context.state.phase == .running, + let step = context.state.currentStep, !step.isEmpty { + Text(step) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} + +// MARK: - Progress bar + +/// A capsule progress bar driven by the job's todo list. Falls back to an +/// indeterminate-looking full accent bar when the job has no todo list, and a +/// solid green bar once the job is done. +private struct JobProgressView: View { + let state: RxCodeJobActivityAttributes.ContentState + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.secondary.opacity(0.25)) + Capsule() + .fill(state.phase == .done ? Color.green : rxAccent) + .frame(width: max(6, geo.size.width * fillFraction)) + } + } + .frame(height: 6) + } + + private var fillFraction: Double { + if state.phase == .done { return 1 } + if state.hasTodos { return state.fractionComplete } + return 0.15 // no todo list: a small "working" sliver + } +} + +// MARK: - Previews + +extension RxCodeJobActivityAttributes { + fileprivate static var preview: RxCodeJobActivityAttributes { + RxCodeJobActivityAttributes( + sessionID: "demo", + projectName: "RxCode", + title: "Add live activity support" + ) + } +} + +extension RxCodeJobActivityAttributes.ContentState { + fileprivate static var running: RxCodeJobActivityAttributes.ContentState { + .init(phase: .running, todoDone: 2, todoTotal: 5, + currentStep: "Implementing widget UI", updatedAt: 0) + } + + fileprivate static var done: RxCodeJobActivityAttributes.ContentState { + .init(phase: .done, todoDone: 5, todoTotal: 5, currentStep: nil, updatedAt: 0) + } +} + +#Preview("Live Activity", as: .content, using: RxCodeJobActivityAttributes.preview) { + RxCodeWidgetLiveActivity() +} contentStates: { + RxCodeJobActivityAttributes.ContentState.running + RxCodeJobActivityAttributes.ContentState.done +} diff --git a/relay-server/README.md b/relay-server/README.md index 9a45c79..77cc9b3 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -13,18 +13,36 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. already prevents reading or forging messages. Drop-on-offline: if the recipient pubkey isn't currently connected, the envelope is dropped and the sender receives a `delivery_failed` notice. -- `POST /push` — desktop submits APNs pushes. Body: - ```json - { - "device_token": "", - "encrypted_alert": "", - "category": "permission_request", // optional - "collapse_id": "" // optional - } - ``` - The relay signs a JWT with the configured APNs auth key and forwards the - push. The encrypted alert blob is decrypted on-device by the iOS - Notification Service Extension before iOS displays the banner. +- `POST /push` — desktop submits APNs pushes. The `push_type` field selects + one of three delivery modes (defaults to `alert`): + - **`alert`** (or omitted) — encrypted banner. Body: + ```json + { + "device_token": "", + "encrypted_alert": "", + "category": "permission_request", // optional + "collapse_id": "" // optional + } + ``` + The encrypted alert blob is decrypted on-device by the iOS Notification + Service Extension before iOS displays the banner. + - **`liveactivity`** — ActivityKit start/update/end push. Body: + ```json + { + "device_token": "", + "push_type": "liveactivity", + "apns_payload": { "aps": { "event": "update", "content-state": { … } } }, + "collapse_id": "" // optional + } + ``` + The relay forwards `apns_payload` verbatim and suffixes the topic with + `.push-type.liveactivity`. Live Activity content-state is **not** E2E + encrypted — ActivityKit consumes it directly. + - **`background`** — silent `content-available` push used to refresh the + home-screen widget. Body is `{ "device_token", "push_type": "background", + "apns_payload" }`; the payload is forwarded verbatim at low priority. + + The relay signs a JWT with the configured APNs auth key and forwards the push. - `GET /healthz` — liveness probe. ## Run locally diff --git a/relay-server/push.go b/relay-server/push.go index e224f1d..73e99d1 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -58,23 +58,47 @@ func NewPushSender(keyPath string, keyPEM []byte, keyID, teamID, topic string, p return &PushSender{client: client, topic: topic}, nil } +// Push delivery modes accepted by POST /push. +const ( + // pushModeAlert is the legacy encrypted-banner path: the desktop ships an + // opaque E2E-encrypted blob and the iOS Notification Service Extension + // decrypts it before the banner is shown. This is the default when + // `push_type` is empty. + pushModeAlert = "alert" + // pushModeLiveActivity carries an ActivityKit start/update/end payload. + // Live Activity content-state cannot be E2E encrypted because ActivityKit + // consumes it directly, so `apns_payload` is forwarded verbatim. + pushModeLiveActivity = "liveactivity" + // pushModeBackground is a silent content-available push used to refresh + // the home-screen widget; `apns_payload` is forwarded verbatim. + pushModeBackground = "background" +) + // PushRequest is the JSON body accepted by POST /push. // -// `EncryptedAlertB64` is an opaque base64 blob produced by the desktop sender — -// the relay never decrypts it. The mobile Notification Service Extension -// decrypts it before iOS shows the banner. +// For the legacy alert path, `encrypted_alert` is an opaque base64 blob +// produced by the desktop sender — the relay never decrypts it. For the +// `liveactivity` and `background` paths, `apns_payload` is the complete APNs +// JSON payload (`{"aps": {…}, …}`) built by the desktop and forwarded verbatim. type PushRequest struct { DeviceToken string `json:"device_token"` - EncryptedAlertB64 string `json:"encrypted_alert"` + EncryptedAlertB64 string `json:"encrypted_alert,omitempty"` Category string `json:"category,omitempty"` CollapseID string `json:"collapse_id,omitempty"` + // PushType selects the delivery mode: "" / "alert", "liveactivity", or + // "background". Unknown values are rejected. + PushType string `json:"push_type,omitempty"` + // APNSPayload is the raw APNs JSON, required for "liveactivity" and + // "background". Ignored for the alert path. + APNSPayload json.RawMessage `json:"apns_payload,omitempty"` } // pushHandler returns an http.HandlerFunc that signs and forwards APNs pushes. // -// Auth is intentionally minimal in v1: any client may submit, since the -// payload itself is E2E-encrypted to the recipient device. A future hardening -// pass should require a signed sender token (see plan: risk areas). +// Auth is intentionally minimal in v1: any client may submit, since the alert +// payload itself is E2E-encrypted to the recipient device. Live Activity and +// widget payloads are not encrypted (ActivityKit / WidgetKit consume them +// directly); a future hardening pass should require a signed sender token. func pushHandler(sender *PushSender) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -95,86 +119,57 @@ func pushHandler(sender *PushSender) http.HandlerFunc { http.Error(w, "malformed body", http.StatusBadRequest) return } - if req.DeviceToken == "" || req.EncryptedAlertB64 == "" { - http.Error(w, "missing fields", http.StatusBadRequest) - return - } - if _, err := base64.StdEncoding.DecodeString(req.EncryptedAlertB64); err != nil { - http.Error(w, "encrypted_alert must be base64", http.StatusBadRequest) + if req.DeviceToken == "" { + http.Error(w, "missing device_token", http.StatusBadRequest) return } - // Payload structure: mutable-content=1 triggers Notification Service - // Extension on the device; the extension decrypts `enc` and rewrites - // the visible alert before display. - payload := map[string]any{ - "aps": map[string]any{ - "alert": map[string]string{ - "title": "RxCode", - "body": "Encrypted notification", - }, - "mutable-content": 1, - "sound": "default", - }, - "enc": req.EncryptedAlertB64, + mode := req.PushType + if mode == "" { + mode = pushModeAlert } - raw, _ := json.Marshal(payload) - notif := &apns2.Notification{ - DeviceToken: req.DeviceToken, - Topic: sender.topic, - Payload: raw, - } - if req.Category != "" { - notif.PushType = apns2.PushTypeAlert + var notif *apns2.Notification + switch mode { + case pushModeAlert: + notif, err = buildAlertNotification(sender, &req) + case pushModeLiveActivity: + notif, err = buildRawNotification(sender, &req, apns2.PushTypeLiveActivity, apns2.PriorityHigh, true) + case pushModeBackground: + notif, err = buildRawNotification(sender, &req, apns2.PushTypeBackground, apns2.PriorityLow, false) + default: + http.Error(w, "unknown push_type", http.StatusBadRequest) + return } - if req.CollapseID != "" { - notif.CollapseID = req.CollapseID + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } + payloadBytes, _ := notif.Payload.([]byte) log.Printf( - "apns push send: device=%s category=%q collapse_id=%q collapse_len=%d payload_bytes=%d enc_bytes=%d", - short(req.DeviceToken), - req.Category, - req.CollapseID, - len(req.CollapseID), - len(raw), - len(req.EncryptedAlertB64), + "apns push send: mode=%s device=%s category=%q collapse_id=%q payload_bytes=%d", + mode, short(req.DeviceToken), req.Category, req.CollapseID, len(payloadBytes), ) res, err := sender.client.Push(notif) if err != nil { log.Printf( - "apns push transport error: %v device=%s category=%q collapse_id=%q collapse_len=%d", - err, - short(req.DeviceToken), - req.Category, - req.CollapseID, - len(req.CollapseID), + "apns push transport error: %v mode=%s device=%s category=%q", + err, mode, short(req.DeviceToken), req.Category, ) http.Error(w, "apns push failed", http.StatusBadGateway) return } if res.Sent() { log.Printf( - "apns push sent: status=%d apns_id=%s device=%s category=%q collapse_id=%q collapse_len=%d", - res.StatusCode, - res.ApnsID, - short(req.DeviceToken), - req.Category, - req.CollapseID, - len(req.CollapseID), + "apns push sent: mode=%s status=%d apns_id=%s device=%s", + mode, res.StatusCode, res.ApnsID, short(req.DeviceToken), ) } else { log.Printf( - "apns push rejected: status=%d reason=%q apns_id=%s device=%s category=%q collapse_id=%q collapse_len=%d", - res.StatusCode, - res.Reason, - res.ApnsID, - short(req.DeviceToken), - req.Category, - req.CollapseID, - len(req.CollapseID), + "apns push rejected: mode=%s status=%d reason=%q apns_id=%s device=%s", + mode, res.StatusCode, res.Reason, res.ApnsID, short(req.DeviceToken), ) } resp := map[string]any{ @@ -186,3 +181,74 @@ func pushHandler(sender *PushSender) http.HandlerFunc { _ = json.NewEncoder(w).Encode(resp) } } + +// buildAlertNotification wraps the desktop's E2E-encrypted blob in the static +// envelope the iOS Notification Service Extension expects. `mutable-content=1` +// triggers the extension, which decrypts `enc` and rewrites the visible alert. +func buildAlertNotification(sender *PushSender, req *PushRequest) (*apns2.Notification, error) { + if req.EncryptedAlertB64 == "" { + return nil, fmt.Errorf("missing encrypted_alert") + } + if _, err := base64.StdEncoding.DecodeString(req.EncryptedAlertB64); err != nil { + return nil, fmt.Errorf("encrypted_alert must be base64") + } + payload := map[string]any{ + "aps": map[string]any{ + "alert": map[string]string{ + "title": "RxCode", + "body": "Encrypted notification", + }, + "mutable-content": 1, + "sound": "default", + }, + "enc": req.EncryptedAlertB64, + } + raw, _ := json.Marshal(payload) + notif := &apns2.Notification{ + DeviceToken: req.DeviceToken, + Topic: sender.topic, + Payload: raw, + } + if req.Category != "" { + notif.PushType = apns2.PushTypeAlert + } + if req.CollapseID != "" { + notif.CollapseID = req.CollapseID + } + return notif, nil +} + +// buildRawNotification forwards the desktop-built APNs payload verbatim. Used +// for Live Activity and background (widget) pushes, whose payloads cannot be +// E2E encrypted because the OS consumes them directly. When `liveActivityTopic` +// is set, the APNs topic is suffixed with `.push-type.liveactivity` as Apple +// requires for Live Activity pushes. +func buildRawNotification( + sender *PushSender, + req *PushRequest, + pushType apns2.EPushType, + priority int, + liveActivityTopic bool, +) (*apns2.Notification, error) { + if len(req.APNSPayload) == 0 { + return nil, fmt.Errorf("missing apns_payload") + } + if !json.Valid(req.APNSPayload) { + return nil, fmt.Errorf("apns_payload must be valid JSON") + } + topic := sender.topic + if liveActivityTopic { + topic = sender.topic + ".push-type.liveactivity" + } + notif := &apns2.Notification{ + DeviceToken: req.DeviceToken, + Topic: topic, + Payload: []byte(req.APNSPayload), + PushType: pushType, + Priority: priority, + } + if req.CollapseID != "" { + notif.CollapseID = req.CollapseID + } + return notif, nil +} From fa1dfc853dc41094091d70faf776017e1128e520 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 00:18:05 +0800 Subject: [PATCH 3/7] fix: reuse job Live Activity instead of re-issuing push-to-start The desktop issued a fresh push-to-start every time a session began streaming, and ended the activity with an auto-dismissal date when it stopped. Re-running the same thread therefore spent a new push-to-start each time, exhausting the iOS push-to-start budget. Once the budget is exhausted, liveactivitiesd accepts the APNs push but silently refuses to start the activity, so the Live Activity never appears. Now an activity is created once per session and reused for its lifetime: it flips between "running" and "done" via update pushes and is never ended or auto-dismissed by the desktop. The user dismisses it manually. The mobile coordinator observes activityStateUpdates and reports the dismissal so the desktop forgets the activity and the next run of that session starts a fresh one. - Payload: add LiveActivityTokenPayload.activityDismissed - MobileLiveActivityCoordinator: observe activityStateUpdates, report dismissal/end to the desktop - MobileSyncService: reuse the activity (running <-> done), drop sendLiveActivityEnd and the dismissal-date, handle the dismissal signal, track isDone per job Co-Authored-By: Claude Opus 4.7 --- .../Sources/RxCodeSync/Protocol/Payload.swift | 8 +- RxCode.xcodeproj/project.pbxproj | 18 +-- RxCode/Services/MobileSyncService.swift | 103 +++++++++++------- .../State/MobileLiveActivityCoordinator.swift | 31 ++++++ RxCodeWidget/RxCodeJobActivity.swift | 3 +- 5 files changed, 112 insertions(+), 51 deletions(-) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 474c315..b3fd63e 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -152,17 +152,23 @@ public struct LiveActivityTokenPayload: Codable, Sendable { public let activityID: String? /// The job (chat session) the activity tracks. public let sessionID: String? + /// `true` when the user dismissed the Live Activity on the device. The + /// desktop then forgets the activity so the next stream of the same session + /// starts a fresh one instead of pushing to a token that no longer renders. + public let activityDismissed: Bool? public init( pushToStartTokenHex: String? = nil, activityTokenHex: String? = nil, activityID: String? = nil, - sessionID: String? = nil + sessionID: String? = nil, + activityDismissed: Bool? = nil ) { self.pushToStartTokenHex = pushToStartTokenHex self.activityTokenHex = activityTokenHex self.activityID = activityID self.sessionID = sessionID + self.activityDismissed = activityDismissed } } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 6dd4681..8c0f973 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -128,14 +128,6 @@ ); target = DF22D8252FBE025C00E3ABFD /* RxCodeWidgetExtension */; }; - DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - RxCodeJobActivity.swift, - RxCodeWidgetData.swift, - ); - target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; - }; DF230B772FBC7368008929A6 /* Exceptions for "RxCodeMobile" folder in "RxCodeMobile" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -150,14 +142,22 @@ ); target = DF230BA42FBC9001008929A6 /* RxCodeMobileNotificationService */; }; + DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + RxCodeJobActivity.swift, + RxCodeWidgetData.swift, + ); + target = DF230B4E2FBC7367008929A6 /* RxCodeMobile */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ DF22D82B2FBE025C00E3ABFD /* RxCodeWidget */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */, DF5A11AC2FBE025D00E3ABF1 /* Exceptions for "RxCodeWidget" folder in "RxCodeMobile" target */, + DF22D83B2FBE025D00E3ABFD /* Exceptions for "RxCodeWidget" folder in "RxCodeWidgetExtension" target */, ); path = RxCodeWidget; sourceTree = ""; diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index c9f024b..f949485 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -472,17 +472,28 @@ final class MobileSyncService: ObservableObject { pushWidgetUpdateIfJobCountChanged() } - /// Start, update, or end a job's Live Activity based on the latest summary. + /// Drive a job's Live Activity from the latest summary. + /// + /// An activity is created once with a push-to-start, then reused for the + /// lifetime of the session: it flips between "running" and "done" via + /// update pushes and is never ended or auto-dismissed by the desktop. This + /// keeps re-runs of the same thread off the scarce iOS push-to-start + /// budget; the user dismisses the activity when they are done with it. private func reconcileJobActivity(summary: RxCodeSync.SessionSummary) { let sessionID = summary.id let content = makeJobContent(from: summary) if summary.isStreaming { if var state = jobActivityState[sessionID] { - let changed = state.lastContent.signature != content.signature + // The activity already exists — reuse it. Flip it back to + // "running" if the job had finished, and push an update + // whenever the rendered state changes. + let contentChanged = state.lastContent.signature != content.signature + let resumed = state.isDone state.lastContent = content + state.isDone = false jobActivityState[sessionID] = state - if changed { - logger.info("[LiveActivity] reconcile decision=update session=\(sessionID, privacy: .public) todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public)") + if contentChanged || resumed { + logger.info("[LiveActivity] reconcile decision=update session=\(sessionID, privacy: .public) resumed=\(resumed, privacy: .public) todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public)") sendLiveActivityUpdate(content, phase: "running") } else { logger.debug("[LiveActivity] reconcile decision=skip-unchanged session=\(sessionID, privacy: .public)") @@ -492,11 +503,19 @@ final class MobileSyncService: ObservableObject { logger.info("[LiveActivity] reconcile decision=start session=\(sessionID, privacy: .public)") sendLiveActivityStart(content) } - } else if jobActivityState[sessionID] != nil { - jobActivityState.removeValue(forKey: sessionID) - logger.info("[LiveActivity] reconcile decision=end session=\(sessionID, privacy: .public)") - sendLiveActivityEnd(content) - clearActivityTokens(sessionID: sessionID) + } else if var state = jobActivityState[sessionID] { + // The job finished. Keep the activity alive in the "done" state so + // it can be reused if the session streams again; the user dismisses + // it manually. We never end or auto-dismiss it ourselves. + guard !state.isDone else { + logger.debug("[LiveActivity] reconcile decision=skip-already-done session=\(sessionID, privacy: .public)") + return + } + state.isDone = true + state.lastContent = content + jobActivityState[sessionID] = state + logger.info("[LiveActivity] reconcile decision=done session=\(sessionID, privacy: .public)") + sendLiveActivityUpdate(content, phase: "done", staleAfter: 8 * 3600) } else { logger.debug("[LiveActivity] reconcile decision=ignore session=\(sessionID, privacy: .public) — not streaming and no active activity") } @@ -548,30 +567,22 @@ final class MobileSyncService: ObservableObject { } } - private func sendLiveActivityUpdate(_ content: JobContent, phase: String) { + /// Push an `update` to a job's activity. `staleAfter` sets when the + /// activity dims to its stale presentation — long for the terminal "done" + /// state, which stays on screen until the user dismisses it. No `end` or + /// `dismissal-date` is ever sent: the desktop keeps the activity alive and + /// only the user dismisses it. + private func sendLiveActivityUpdate(_ content: JobContent, phase: String, staleAfter: TimeInterval = 3600) { let now = Date() let payload: [String: Any] = ["aps": [ "timestamp": Int(now.timeIntervalSince1970), "event": "update", "content-state": contentStateDict(content, phase: phase, at: now), - "stale-date": Int(now.addingTimeInterval(3600).timeIntervalSince1970), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), ]] pushToActivityTokens(sessionID: content.sessionID, payload: payload) } - private func sendLiveActivityEnd(_ content: JobContent) { - let now = Date() - let payload: [String: Any] = ["aps": [ - "timestamp": Int(now.timeIntervalSince1970), - "event": "end", - "content-state": contentStateDict(content, phase: "done", at: now), - // Keep the "Done" state on screen briefly, then auto-dismiss. - "dismissal-date": Int(now.addingTimeInterval(300).timeIntervalSince1970), - ]] - logger.info("[LiveActivity] end session=\(content.sessionID, privacy: .public)") - pushToActivityTokens(sessionID: content.sessionID, payload: payload) - } - /// Build the ActivityKit `content-state` dict. Field names mirror /// `RxCodeJobActivityAttributes.ContentState` in the widget target. private func contentStateDict(_ content: JobContent, phase: String, at date: Date) -> [String: Any] { @@ -611,18 +622,6 @@ final class MobileSyncService: ObservableObject { } } - /// Forget the per-activity tokens of a finished job. - private func clearActivityTokens(sessionID: String) { - var changed = false - for i in pairedDevices.indices { - guard let refs = pairedDevices[i].liveActivityTokens, - refs.contains(where: { $0.sessionID == sessionID }) else { continue } - pairedDevices[i].liveActivityTokens = refs.filter { $0.sessionID != sessionID } - changed = true - } - if changed { savePairedDevices() } - } - private func pushWidgetUpdateIfJobCountChanged() { guard streamingSessionIDs.count != lastWidgetJobCount else { return } pushWidgetUpdate() @@ -744,22 +743,42 @@ final class MobileSyncService: ObservableObject { pairedDevices[idx].liveActivityStartToken = startToken logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") } - if let activityToken = t.activityTokenHex, !activityToken.isEmpty, - let activityID = t.activityID { + if t.activityDismissed == true { + // The user swiped the Live Activity away. Forget the activity + // and its token so the next stream of this session starts a + // fresh one instead of pushing to a token that no longer + // renders anything. + let sessionID = t.sessionID ?? "" + if !sessionID.isEmpty { + jobActivityState.removeValue(forKey: sessionID) + } + if var refs = pairedDevices[idx].liveActivityTokens { + refs.removeAll { + $0.activityID == t.activityID || (!sessionID.isEmpty && $0.sessionID == sessionID) + } + pairedDevices[idx].liveActivityTokens = refs + } + logger.info("[LiveActivity] activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) session=\(sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, + let activityID = t.activityID { let ref = LiveActivityTokenRef( activityID: activityID, sessionID: t.sessionID ?? "", token: activityToken ) var refs = pairedDevices[idx].liveActivityTokens ?? [] - refs.removeAll { $0.activityID == activityID } + // One activity per session — drop any prior ref for the same + // activity or session so stale tokens don't accumulate. + refs.removeAll { + $0.activityID == activityID || (!ref.sessionID.isEmpty && $0.sessionID == ref.sessionID) + } refs.append(ref) pairedDevices[idx].liveActivityTokens = refs logger.info("[LiveActivity] activity token registered activity=\(activityID, privacy: .public) session=\(ref.sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") // Push the latest known state straight away so a freshly // started activity isn't left blank until the next change. if !ref.sessionID.isEmpty, let st = jobActivityState[ref.sessionID] { - sendLiveActivityUpdate(st.lastContent, phase: "running") + sendLiveActivityUpdate(st.lastContent, phase: st.isDone ? "done" : "running") } } pairedDevices[idx].lastSeen = .now @@ -1073,6 +1092,10 @@ private struct JobContent { /// Per-job Live Activity bookkeeping, keyed by session id in `MobileSyncService`. private struct JobActivityState { var lastContent: JobContent + /// `true` once the job has finished and its activity shows the terminal + /// "done" state. Kept so a re-stream of the same session reuses the + /// activity instead of issuing another budget-limited push-to-start. + var isDone: Bool = false } extension Notification.Name { diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift index 106072d..aaf1099 100644 --- a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -105,6 +105,18 @@ final class MobileLiveActivityCoordinator { self?.handleActivityToken(activity: activity, hex: hex) } } + // The desktop reuses an activity across re-runs and never ends it + // itself, so the only way it goes away is the user dismissing it. + // Report that so the desktop forgets the activity and the next run + // push-to-starts a fresh one instead of pushing to a dead token. + Task { [weak self] in + for await activityState in activity.activityStateUpdates { + if activityState == .dismissed || activityState == .ended { + self?.handleActivityDismissed(activity, activityState: activityState) + break + } + } + } } private func handleStartToken(_ hex: String) { @@ -130,6 +142,25 @@ final class MobileLiveActivityCoordinator { } } + /// The user dismissed (or the system ended) an activity. Drop our local + /// token and tell the desktop so it forgets the activity — the next run of + /// this session will then push-to-start a fresh one. + @available(iOS 16.1, *) + private func handleActivityDismissed( + _ activity: Activity, + activityState: ActivityState + ) { + let sessionID = activity.attributes.sessionID + let activityID = activity.id + activityTokens.removeValue(forKey: activityID) + logger.info("[LiveActivity] activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) session=\(sessionID, privacy: .public) — reporting dismissal to desktop") + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityID: activityID, sessionID: sessionID, activityDismissed: true + )) + } + } + /// Re-send every token we hold. Called when the relay reconnects so the /// desktop's per-device token registry survives a disconnect. private func resendTokens() { diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift index bc4f0e3..616320b 100644 --- a/RxCodeWidget/RxCodeJobActivity.swift +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -26,7 +26,8 @@ struct RxCodeJobActivityAttributes: ActivityAttributes { enum Phase: String, Codable, Hashable { /// The agent is still working. case running - /// The agent finished — the activity shows "Done" before dismissal. + /// The agent finished. The activity stays in this state until the + /// user dismisses it — the desktop never ends it automatically. case done } From d71a0f5461a621a65a6e2e9932c1addde64616d1 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 00:48:25 +0800 Subject: [PATCH 4/7] feat: inject branch briefing and curated skills into agent context - Add `extraSystemPrompt` to BackendSendRequest and thread it through ClaudeService into a single `--append-system-prompt` value - Surface the current branch briefing and installed-skill context to the Claude agent as background context - Fetch OpenAI curated skills into the marketplace, store SKILL.md instructions, and clip skill prompt context to a token budget - Defer Live Activity push-to-start so a foregrounded device can start the activity locally without spending the push-to-start budget - Show an active-jobs chip on mobile briefing cards Co-Authored-By: Claude Opus 4.7 --- .../RxCodeCore/Backend/AgentBackend.swift | 5 + .../RxCodeCore/Models/MarketplacePlugin.swift | 14 +- RxCode/App/AppState.swift | 45 ++++++ RxCode/Services/ClaudeService.swift | 24 ++- RxCode/Services/MarketplaceService.swift | 146 +++++++++++++++++- RxCode/Services/MobileSyncService.swift | 48 +++++- .../State/MobileLiveActivityCoordinator.swift | 68 ++++++++ RxCodeMobile/Views/MobileBriefingView.swift | 52 ++++++- 8 files changed, 389 insertions(+), 13 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift b/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift index 2a95614..5eec29d 100644 --- a/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift +++ b/Packages/Sources/RxCodeCore/Backend/AgentBackend.swift @@ -18,6 +18,9 @@ public struct BackendSendRequest: Sendable { public let hookSettingsPath: String? /// Path to the Claude MCP config JSON written for this turn. Claude-only. public let mcpClaudeConfigPath: String? + /// Extra text appended to the agent's system prompt for this turn — e.g. the + /// accumulated briefing for the project's current branch. Claude-only. + public let extraSystemPrompt: String? /// `-c` overrides handed to the Codex app-server child. Codex-only. public let mcpCodexOverrides: [String] /// JSON-RPC payload for ACP's `session/new` `mcpServers` parameter. @@ -39,6 +42,7 @@ public struct BackendSendRequest: Sendable { planMode: Bool = false, hookSettingsPath: String? = nil, mcpClaudeConfigPath: String? = nil, + extraSystemPrompt: String? = nil, mcpCodexOverrides: [String] = [], acpMCPServers: [JSONValue] = [], acpSpec: ACPClientSpec? = nil, @@ -54,6 +58,7 @@ public struct BackendSendRequest: Sendable { self.planMode = planMode self.hookSettingsPath = hookSettingsPath self.mcpClaudeConfigPath = mcpClaudeConfigPath + self.extraSystemPrompt = extraSystemPrompt self.mcpCodexOverrides = mcpCodexOverrides self.acpMCPServers = acpMCPServers self.acpSpec = acpSpec diff --git a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift index 9042847..fb4478b 100644 --- a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift +++ b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift @@ -31,6 +31,7 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case url case gitSubdir = "git-subdir" case skillsBundle = "skills-bundle" + case agentSkill = "agent-skill" } public var categoryLabel: String { @@ -42,6 +43,7 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case "agent-skills": return "Agent Skills" case "knowledge-work": return "Knowledge Work" case "financial-services": return "Financial Services" + case "codex-curated": return "Codex Curated" default: return category.replacingOccurrences(of: "-", with: " ").capitalized } } @@ -52,12 +54,13 @@ public struct MarketplacePlugin: Identifiable, Codable, Sendable, Hashable { case "anthropic-agent-skills": return "Agent Skills" case "knowledge-work-plugins": return "Knowledge Work" case "financial-services-plugins": return "Financial Services" + case "openai-skills-curated": return "Codex Curated" default: return marketplace } } public var installCommand: String { - "/plugin install \(name)@\(marketplace)" + "Install \(name) from \(marketplace)" } } @@ -87,6 +90,9 @@ public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable public var summary: String? public var category: String? public var marketplaceSource: MarketplaceSource? + public var sourceType: MarketplacePlugin.SourceType? + public var skillPaths: [String]? + public var instructions: String? public var installedAt: Date public var isGloballyEnabled: Bool public var enabledProviders: Set @@ -97,6 +103,9 @@ public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable summary: String? = nil, category: String? = nil, marketplaceSource: MarketplaceSource?, + sourceType: MarketplacePlugin.SourceType? = nil, + skillPaths: [String]? = nil, + instructions: String? = nil, installedAt: Date = Date(), isGloballyEnabled: Bool = true, enabledProviders: Set = Set(AgentProvider.allCases) @@ -106,6 +115,9 @@ public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable self.summary = summary self.category = category self.marketplaceSource = marketplaceSource + self.sourceType = sourceType + self.skillPaths = skillPaths + self.instructions = instructions self.installedAt = installedAt self.isGloballyEnabled = isGloballyEnabled self.enabledProviders = enabledProviders diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 2e8753a..9521837 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -4257,6 +4257,24 @@ final class AppState { } } + /// Wrap a branch briefing into a system-prompt section the agent can use as + /// background context. The briefing is auto-generated from earlier threads, + /// so it is framed as advisory rather than authoritative. + private static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { + let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return """ + # Current branch briefing + + The notes below are an accumulated briefing of recent work on this \ + project's current branch (`\(branch)`). They are auto-generated from \ + previous chat threads — treat them as background context for the user's \ + request, and be aware they may be incomplete or slightly out of date. + + \(trimmed) + """ + } + private func processStream( streamId: UUID, prompt: String, @@ -4284,6 +4302,7 @@ final class AppState { // Resolve per-backend send-request fields (MCP injection, ACP client // spec, model split) before dispatching through the unified protocol. var mcpClaudeConfigPath: String? = nil + var extraSystemPrompt: String? = nil var mcpCodexOverrides: [String] = [] var acpMCPServers: [JSONValue] = [] var acpSpec: ACPClientSpec? = nil @@ -4292,6 +4311,16 @@ final class AppState { var resolvedSendMode: PermissionMode = permissionMode var earlyStream: AsyncStream? = nil + func appendExtraSystemPrompt(_ context: String) { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if let existing = extraSystemPrompt, !existing.isEmpty { + extraSystemPrompt = "\(existing)\n\n\(trimmed)" + } else { + extraSystemPrompt = trimmed + } + } + switch agentProvider { case .claudeCode: // Allocate a per-session IDE-MCP port so the Claude agent can call @@ -4304,9 +4333,24 @@ final class AppState { ) let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) + // Surface the accumulated briefing for the project's current branch + // to the agent as background context via `--append-system-prompt`. + if let branch = await GitHelper.currentBranch(at: cwd), + let briefing = threadStore.branchBriefingItem(projectId: projectId, branch: branch) { + extraSystemPrompt = Self.branchBriefingSystemPrompt( + branch: branch, + briefing: briefing.briefing + ) + } + if let skillContext = await marketplace.promptContext(for: .claudeCode) { + appendExtraSystemPrompt(skillContext) + } case .codex: mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd) mcpCodexOverrides += await marketplace.codexConfigOverrides() + if let skillContext = await marketplace.promptContext(for: .codex) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(prompt)" + } resolvedSendMode = registerMode case .acp: // Allocate a per-session IDE-MCP port so the ACP agent can call @@ -4368,6 +4412,7 @@ final class AppState { planMode: permissionMode == .plan, hookSettingsPath: hookSettingsPath, mcpClaudeConfigPath: mcpClaudeConfigPath, + extraSystemPrompt: extraSystemPrompt, mcpCodexOverrides: mcpCodexOverrides, acpMCPServers: acpMCPServers, acpSpec: acpSpec, diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 8fa5edb..39caaba 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -473,6 +473,7 @@ actor ClaudeCodeServer { effort: String? = nil, hookSettingsPath: String? = nil, mcpConfigPath: String? = nil, + extraSystemPrompt: String? = nil, permissionMode: PermissionMode = .default ) -> AsyncStream { let stdin = Pipe() @@ -502,6 +503,7 @@ actor ClaudeCodeServer { effort: effort, hookSettingsPath: hookSettingsPath, mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, permissionMode: permissionMode, stdinPipe: stdin, stdoutPipe: stdout, @@ -800,6 +802,7 @@ actor ClaudeCodeServer { effort: String?, hookSettingsPath: String?, mcpConfigPath: String?, + extraSystemPrompt: String?, permissionMode: PermissionMode ) -> [String] { var args: [String] = [ @@ -834,9 +837,21 @@ actor ClaudeCodeServer { if let mcpConfigPath { args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath] - // The `rxcode-ide` MCP server is part of this config — tell the - // agent the IDE multi-agent / introspection tools exist. - args += ["--append-system-prompt", Self.ideToolsSystemPrompt] + } + + // Assemble the system-prompt additions for this turn into a single + // `--append-system-prompt` value (the CLI honours one occurrence): + // - IDE tools blurb, when the `rxcode-ide` MCP server is wired in. + // - Caller-supplied context, e.g. the current branch briefing. + var systemPromptSections: [String] = [] + if mcpConfigPath != nil { + systemPromptSections.append(Self.ideToolsSystemPrompt) + } + if let extraSystemPrompt, !extraSystemPrompt.isEmpty { + systemPromptSections.append(extraSystemPrompt) + } + if !systemPromptSections.isEmpty { + args += ["--append-system-prompt", systemPromptSections.joined(separator: "\n\n")] } if let sessionId { @@ -871,6 +886,7 @@ actor ClaudeCodeServer { effort: String? = nil, hookSettingsPath: String?, mcpConfigPath: String?, + extraSystemPrompt: String? = nil, permissionMode: PermissionMode = .default, stdinPipe: Pipe, stdoutPipe: Pipe, @@ -887,6 +903,7 @@ actor ClaudeCodeServer { effort: effort, hookSettingsPath: hookSettingsPath, mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, permissionMode: permissionMode ) let environment = await resolvedEnvironment() @@ -1219,6 +1236,7 @@ extension ClaudeCodeServer: AgentBackend { effort: request.effort, hookSettingsPath: request.hookSettingsPath, mcpConfigPath: request.mcpClaudeConfigPath, + extraSystemPrompt: request.extraSystemPrompt, permissionMode: request.permissionMode ) } diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index b438ec0..eccc3a4 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -21,6 +21,7 @@ actor MarketplaceService { ("anthropics", "knowledge-work-plugins", "knowledge-work"), ("anthropics", "financial-services-plugins", "financial-services"), ] + private static let openAISkillsSource = MarketplaceSource(owner: "openai", repo: "skills") // MARK: - Fetch Catalog @@ -35,6 +36,9 @@ actor MarketplaceService { var allPlugins: [MarketplacePlugin] = [] await withTaskGroup(of: [MarketplacePlugin].self) { group in + group.addTask { + await self.fetchOpenAISkills() + } for source in Self.sourceRepos { group.addTask { await self.fetchRepoPlugins( @@ -58,6 +62,59 @@ actor MarketplaceService { return allPlugins } + private func fetchOpenAISkills() async -> [MarketplacePlugin] { + let apiURL = "https://api.github.com/repos/openai/skills/contents/skills/.curated?ref=main" + guard let url = URL(string: apiURL) else { return [] } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let entries = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + + let names = entries.compactMap { entry -> String? in + guard (entry["type"] as? String) == "dir" else { return nil } + return entry["name"] as? String + } + + var skills: [MarketplacePlugin] = [] + await withTaskGroup(of: MarketplacePlugin?.self) { group in + for name in names { + group.addTask { await self.fetchOpenAISkill(named: name) } + } + for await skill in group { + if let skill { skills.append(skill) } + } + } + return skills + } catch { + logger.warning("Failed to fetch OpenAI skills catalog: \(error.localizedDescription)") + return [] + } + } + + private func fetchOpenAISkill(named name: String) async -> MarketplacePlugin? { + let path = "skills/.curated/\(name)" + guard let instructions = await fetchRawSkillInstructions(source: Self.openAISkillsSource, path: path) else { + return nil + } + + let metadata = parseSkillMetadata(instructions) + return MarketplacePlugin( + name: metadata.name ?? name, + description: metadata.description ?? "", + author: "OpenAI", + category: "codex-curated", + homepage: "https://github.com/openai/skills/tree/main/\(path)", + marketplace: "openai-skills-curated", + marketplaceSource: Self.openAISkillsSource, + sourceType: .agentSkill, + skillPaths: [path] + ) + } + // MARK: - Fetch Repository private func fetchRepoPlugins(owner: String, repo: String, defaultCategory: String) async -> [MarketplacePlugin] { @@ -184,7 +241,9 @@ actor MarketplaceService { marketplace: plugin.marketplace, summary: plugin.description, category: plugin.category, - marketplaceSource: plugin.marketplaceSource + marketplaceSource: plugin.marketplaceSource, + sourceType: plugin.sourceType, + skillPaths: plugin.skillPaths )) changed = true } @@ -200,12 +259,16 @@ actor MarketplaceService { /// Install into RxCode-owned state and mirror to Claude Code when available. func installPlugin(_ plugin: MarketplacePlugin) async throws { var config = try loadConfig() + let instructions = await skillInstructions(for: plugin) let record = MarketplacePluginRecord( name: plugin.name, marketplace: plugin.marketplace, summary: plugin.description, category: plugin.category, - marketplaceSource: plugin.marketplaceSource + marketplaceSource: plugin.marketplaceSource, + sourceType: plugin.sourceType, + skillPaths: plugin.skillPaths, + instructions: instructions ) if let index = config.plugins.firstIndex(where: { $0.id == record.id }) { @@ -214,6 +277,9 @@ actor MarketplaceService { config.plugins[index].marketplaceSource = plugin.marketplaceSource config.plugins[index].summary = plugin.description config.plugins[index].category = plugin.category + config.plugins[index].sourceType = plugin.sourceType + config.plugins[index].skillPaths = plugin.skillPaths + config.plugins[index].instructions = instructions } else { config.plugins.append(record) } @@ -279,22 +345,88 @@ actor MarketplaceService { .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } guard !records.isEmpty else { return nil } - var lines = ["Installed RxCode skills available for this session:"] + var lines = ["# Installed RxCode skills\nUse the following installed skills when they match the user's request."] + var remainingBudget = 18_000 for record in records { - let detail = record.summary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let detail = (record.instructions ?? record.summary)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let clipped = String(detail.prefix(max(0, min(remainingBudget, 6_000)))) if detail.isEmpty { - lines.append("- \(record.name) from \(record.marketplace)") + lines.append("## \(record.name)\nSource: \(record.marketplace)") } else { - lines.append("- \(record.name) from \(record.marketplace): \(detail)") + lines.append("## \(record.name)\nSource: \(record.marketplace)\n\n\(clipped)") + remainingBudget -= clipped.count + if remainingBudget <= 0 { break } } } - return lines.joined(separator: "\n") + return lines.joined(separator: "\n\n") } catch { logger.warning("Failed to build skill prompt context: \(error.localizedDescription)") return nil } } + private func skillInstructions(for plugin: MarketplacePlugin) async -> String? { + guard let source = plugin.marketplaceSource else { return nil } + for path in plugin.skillPaths { + if let instructions = await fetchRawSkillInstructions(source: source, path: path) { + return instructions + } + } + return nil + } + + private func fetchRawSkillInstructions(source: MarketplaceSource, path: String) async -> String? { + let cleanPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let ref = source.ref?.isEmpty == false ? source.ref! : "main" + let rawURL = "https://raw.githubusercontent.com/\(source.owner)/\(source.repo)/\(ref)/\(cleanPath)/SKILL.md" + guard let url = URL(string: rawURL) else { return nil } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + return String(data: data, encoding: .utf8) + } catch { + logger.warning("Failed to fetch skill instructions at \(rawURL, privacy: .public): \(error.localizedDescription)") + return nil + } + } + + private func parseSkillMetadata(_ text: String) -> (name: String?, description: String?) { + guard text.hasPrefix("---"), + let end = text.dropFirst(3).range(of: "---") else { + return (nil, nil) + } + + let frontMatter = text[text.index(text.startIndex, offsetBy: 3).. String { + var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= 2, + let first = trimmed.first, + let last = trimmed.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") { + trimmed.removeFirst() + trimmed.removeLast() + } + return trimmed + } + // MARK: - RxCode Config private func loadConfig() throws -> MarketplacePluginConfiguration { diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index f949485..25a5970 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -104,6 +104,11 @@ final class MobileSyncService: ObservableObject { private var streamingSessionIDs: Set = [] /// Per-job Live Activity bookkeeping, keyed by session id. private var jobActivityState: [String: JobActivityState] = [:] + /// Pending delayed push-to-start tasks, keyed by session id. The + /// push-to-start is deferred briefly so a foregrounded device can instead + /// start the activity locally; the task is cancelled once that device + /// reports a per-activity token. + private var pendingStartTasks: [String: Task] = [:] /// Last widget job count pushed, so a widget push only fires on a change. private var lastWidgetJobCount: Int = -1 @@ -501,7 +506,7 @@ final class MobileSyncService: ObservableObject { } else { jobActivityState[sessionID] = JobActivityState(lastContent: content) logger.info("[LiveActivity] reconcile decision=start session=\(sessionID, privacy: .public)") - sendLiveActivityStart(content) + scheduleLiveActivityStart(for: sessionID) } } else if var state = jobActivityState[sessionID] { // The job finished. Keep the activity alive in the "done" state so @@ -511,6 +516,8 @@ final class MobileSyncService: ObservableObject { logger.debug("[LiveActivity] reconcile decision=skip-already-done session=\(sessionID, privacy: .public)") return } + // The job is already finished — no need to push-to-start it. + cancelPendingStart(sessionID) state.isDone = true state.lastContent = content jobActivityState[sessionID] = state @@ -532,6 +539,39 @@ final class MobileSyncService: ObservableObject { ) } + /// Schedule the push-to-start after a short delay. A foregrounded mobile + /// starts the activity itself (no push-to-start budget) and reports its + /// token within a second or two; that token cancels this task. Only a + /// backgrounded device — which cannot start an activity on its own — ends + /// up actually receiving the push-to-start. + private func scheduleLiveActivityStart(for sessionID: String) { + pendingStartTasks[sessionID]?.cancel() + pendingStartTasks[sessionID] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self else { return } + self.pendingStartTasks.removeValue(forKey: sessionID) + guard let job = self.jobActivityState[sessionID], !job.isDone else { return } + let hasToken = self.pairedDevices.contains { device in + (device.liveActivityTokens ?? []).contains { $0.sessionID == sessionID } + } + guard !hasToken else { + self.logger.info("[LiveActivity] push-to-start skipped session=\(sessionID, privacy: .public) — device started the activity locally") + return + } + self.logger.info("[LiveActivity] push-to-start firing session=\(sessionID, privacy: .public) — no local activity was reported") + self.sendLiveActivityStart(job.lastContent) + } + } + + /// Cancel a pending push-to-start — the activity already exists, or the + /// job ended before the delay elapsed. + private func cancelPendingStart(_ sessionID: String) { + if let task = pendingStartTasks.removeValue(forKey: sessionID) { + task.cancel() + logger.debug("[LiveActivity] pending push-to-start cancelled session=\(sessionID, privacy: .public)") + } + } + /// Push a `start` Live Activity to every device with a push-to-start token. private func sendLiveActivityStart(_ content: JobContent) { let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } @@ -751,6 +791,7 @@ final class MobileSyncService: ObservableObject { let sessionID = t.sessionID ?? "" if !sessionID.isEmpty { jobActivityState.removeValue(forKey: sessionID) + cancelPendingStart(sessionID) } if var refs = pairedDevices[idx].liveActivityTokens { refs.removeAll { @@ -775,6 +816,11 @@ final class MobileSyncService: ObservableObject { refs.append(ref) pairedDevices[idx].liveActivityTokens = refs logger.info("[LiveActivity] activity token registered activity=\(activityID, privacy: .public) session=\(ref.sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + // A token means the activity exists — drop any pending + // push-to-start so a second activity is never created. + if !ref.sessionID.isEmpty { + cancelPendingStart(ref.sessionID) + } // Push the latest known state straight away so a freshly // started activity isn't left blank until the next change. if !ref.sessionID.isEmpty, let st = jobActivityState[ref.sessionID] { diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift index aaf1099..eadc7a1 100644 --- a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -20,7 +20,9 @@ import ActivityKit import Combine import Foundation +import RxCodeCore import RxCodeSync +import UIKit import os.log @MainActor @@ -32,6 +34,10 @@ final class MobileLiveActivityCoordinator { private var startTokenHex: String? /// Per-activity update tokens, keyed by `Activity.id`. private var activityTokens: [String: (sessionID: String, tokenHex: String)] = [:] + /// Sessions for which we started a Live Activity locally (foreground path). + /// Tracked so a burst of session updates can't start the same one twice; + /// cleared when the user dismisses the activity. + private var locallyStartedSessions: Set = [] private var observationStarted = false private var cancellables = Set() @@ -51,6 +57,16 @@ final class MobileLiveActivityCoordinator { } } .store(in: &cancellables) + // When the app is in the foreground a job's Live Activity can be + // started locally with `Activity.request` — no push-to-start budget. + // Watch the mirrored session list and start one as soon as a job + // begins streaming; backgrounded jobs still use the desktop's push. + state.$sessions + .receive(on: DispatchQueue.main) + .sink { [weak self] sessions in + self?.startLocalActivitiesIfForeground(for: sessions) + } + .store(in: &cancellables) } private func startObserving() { @@ -153,6 +169,7 @@ final class MobileLiveActivityCoordinator { let sessionID = activity.attributes.sessionID let activityID = activity.id activityTokens.removeValue(forKey: activityID) + locallyStartedSessions.remove(sessionID) logger.info("[LiveActivity] activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) session=\(sessionID, privacy: .public) — reporting dismissal to desktop") Task { [weak state] in await state?.sendLiveActivityToken(LiveActivityTokenPayload( @@ -161,6 +178,57 @@ final class MobileLiveActivityCoordinator { } } + // MARK: - Foreground local start + + /// Start a local Live Activity for every streaming job that doesn't have + /// one yet — but only while the app is in the foreground, where + /// `Activity.request` works without spending the push-to-start budget. + private func startLocalActivitiesIfForeground(for sessions: [SessionSummary]) { + guard #available(iOS 16.2, *) else { return } + guard UIApplication.shared.applicationState == .active else { return } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let live = Activity.activities + for session in sessions where session.isStreaming { + let sid = session.id + guard !locallyStartedSessions.contains(sid), + !live.contains(where: { $0.attributes.sessionID == sid }) + else { continue } + startActivityLocally(for: session) + } + } + + /// Create the activity in-process and observe it so its push token reaches + /// the desktop, which then drives updates exactly as for a pushed activity. + @available(iOS 16.2, *) + private func startActivityLocally(for session: SessionSummary) { + let sid = session.id + let projectName = state?.projects.first { $0.id == session.projectId }?.name ?? "" + let attributes = RxCodeJobActivityAttributes( + sessionID: sid, projectName: projectName, title: session.title + ) + let contentState = RxCodeJobActivityAttributes.ContentState( + phase: .running, + todoDone: session.progress?.done ?? 0, + todoTotal: session.progress?.total ?? 0, + currentStep: session.todos?.first { $0.status == .inProgress }?.activeForm, + updatedAt: Date().timeIntervalSince1970 + ) + do { + let activity = try Activity.request( + attributes: attributes, + content: ActivityContent( + state: contentState, staleDate: Date().addingTimeInterval(3600) + ), + pushType: .token + ) + locallyStartedSessions.insert(sid) + logger.info("[LiveActivity] started locally session=\(sid, privacy: .public) id=\(activity.id, privacy: .public) — foreground, no push-to-start needed") + observe(activity) + } catch { + logger.error("[LiveActivity] local start failed session=\(sid, privacy: .public): \(error.localizedDescription, privacy: .public) — desktop push-to-start will cover it") + } + } + /// Re-send every token we hold. Called when the relay reconnects so the /// desktop's per-device token registry survives a disconnect. private func resendTokens() { diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 7b12ca5..0481f85 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -49,6 +49,7 @@ struct MobileBriefingView: View { BriefingCard( group: group, projectName: projectsById[group.projectId]?.name ?? "Unknown Project", + activeJobCount: activeJobCountByProject[group.projectId] ?? 0, namespace: glassNamespace ) } @@ -123,6 +124,17 @@ struct MobileBriefingView: View { Dictionary(uniqueKeysWithValues: state.projects.map { ($0.id, $0) }) } + /// Number of active (streaming) jobs per project. `SessionSummary` only + /// carries `projectId`, not a branch, so the count is project-scoped and + /// every branch card for a project shares it. + private var activeJobCountByProject: [UUID: Int] { + var counts: [UUID: Int] = [:] + for session in state.sessions where session.isStreaming { + counts[session.projectId, default: 0] += 1 + } + return counts + } + /// Every briefing group before the project/branch filters are applied. private var allGroups: [GroupedBriefing] { groupBriefings(briefings: state.branchBriefings, threads: state.threadSummaries) @@ -253,6 +265,7 @@ struct MobileBriefingView: View { private struct BriefingCard: View { let group: GroupedBriefing let projectName: String + let activeJobCount: Int let namespace: Namespace.ID private var threadCount: Int { group.threads.count } @@ -312,6 +325,11 @@ private struct BriefingCard: View { // Footer with metadata HStack(spacing: 12) { + // Active jobs chip — pulses while jobs are running + if activeJobCount > 0 { + ActiveJobsChip(count: activeJobCount) + } + // Thread count chip if threadCount > 0 { HStack(spacing: 4) { @@ -325,7 +343,7 @@ private struct BriefingCard: View { .padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) } - + Spacer(minLength: 0) // Time ago @@ -366,6 +384,38 @@ private struct BriefingCard: View { } } +// MARK: - Active Jobs Chip + +/// Footer chip showing how many jobs are actively streaming for a project. +/// The dot pulses to convey live progress. +private struct ActiveJobsChip: View { + let count: Int + + @State private var isPulsing = false + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + .opacity(isPulsing ? 0.35 : 1) + .scaleEffect(isPulsing ? 0.85 : 1) + Text("\(count) active") + .font(.caption.weight(.medium)) + } + .foregroundStyle(.green) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + .accessibilityLabel("\(count) active jobs") + } +} + func groupBriefings( briefings: [MobileBranchBriefing], threads: [MobileThreadSummary] From 217754c06c843087e00ee2ef3ce2bcb5c7f8984e Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 01:22:26 +0800 Subject: [PATCH 5/7] feat: add change view to mobile and rewrite the live activity --- .../RxCodeChatKit/ChangeDiffView.swift | 103 +++++ .../RxCodeCore/Utilities/GitHelper.swift | 113 ++++++ .../Sources/RxCodeSync/Protocol/Payload.swift | 131 +++++- README.md | 2 +- RxCode/App/AppState.swift | 82 ++++ RxCode/Services/MarketplaceService.swift | 59 ++- RxCode/Services/MobileSyncService.swift | 366 +++++++++-------- RxCode/Views/Chat/SkillMarketView.swift | 15 +- RxCode/Views/UserManualView.swift | 2 +- RxCodeMobile/State/MobileAppState.swift | 34 +- .../State/MobileLiveActivityCoordinator.swift | 207 ++++++---- RxCodeMobile/Views/MobileChatView.swift | 12 + RxCodeMobile/Views/ThreadChangesSheet.swift | 346 ++++++++++++++++ RxCodeWidget/RxCodeJobActivity.swift | 99 +++-- RxCodeWidget/RxCodeWidgetLiveActivity.swift | 376 +++++++++++++----- 15 files changed, 1556 insertions(+), 391 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/ChangeDiffView.swift create mode 100644 RxCodeMobile/Views/ThreadChangesSheet.swift diff --git a/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift b/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift new file mode 100644 index 0000000..06a0dbc --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/ChangeDiffView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import RxCodeCore + +/// Full, non-collapsing diff renderer for a single file. Used by the mobile +/// "View Changes" detail page, which owns a whole screen and therefore renders +/// every diff line. Accepts either a raw unified diff string (git changes) or a +/// set of old/new edit-hunk pairs (thread file edits). +/// +/// The +/- coloring intentionally mirrors `ToolResultView`'s inline chat diffs. +public struct ChangeDiffView: View { + private enum Source { + case unified(String) + case hunks([PreviewFile.EditHunk]) + } + + private let source: Source + + /// Renders a raw unified diff, e.g. `git diff` output. + public init(unifiedDiff: String) { + source = .unified(unifiedDiff) + } + + /// Renders old/new replacement pairs as a removed-then-added diff. + public init(hunks: [PreviewFile.EditHunk]) { + source = .hunks(hunks) + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + switch source { + case .unified(let diff): + unifiedRows(diff) + case .hunks(let hunks): + hunkRows(hunks) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + // MARK: - Unified diff + + @ViewBuilder + private func unifiedRows(_ diff: String) -> some View { + let lines = diff.components(separatedBy: .newlines) + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + diffRow( + text: line.isEmpty ? " " : line, + color: unifiedColor(line), + background: unifiedBackground(line) + ) + } + } + + // MARK: - Edit hunks + + @ViewBuilder + private func hunkRows(_ hunks: [PreviewFile.EditHunk]) -> some View { + ForEach(Array(hunks.enumerated()), id: \.offset) { index, hunk in + if index > 0 { + Divider().padding(.vertical, 4) + } + let removed = hunk.oldString + .components(separatedBy: .newlines) + .map { ("- " + $0, ClaudeTheme.statusError, ClaudeTheme.statusError.opacity(0.06)) } + let added = hunk.newString + .components(separatedBy: .newlines) + .map { ("+ " + $0, ClaudeTheme.statusSuccess, ClaudeTheme.statusSuccess.opacity(0.06)) } + ForEach(Array((removed + added).enumerated()), id: \.offset) { _, item in + diffRow(text: item.0, color: item.1, background: item.2) + } + } + } + + // MARK: - Shared row + + private func diffRow(text: String, color: Color, background: Color) -> some View { + ChatTextContentView( + text, + size: ClaudeTheme.messageSize(12), + design: .monospaced, + color: color + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(background) + } + + private func unifiedColor(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError } + if line.hasPrefix("@@") { return ClaudeTheme.accent } + return ClaudeTheme.textPrimary + } + + private func unifiedBackground(_ line: String) -> Color { + if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess.opacity(0.06) } + if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError.opacity(0.06) } + if line.hasPrefix("@@") { return ClaudeTheme.accent.opacity(0.08) } + return Color.clear + } +} diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index 78c1cfd..0bdb113 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -247,6 +247,119 @@ public enum GitHelper { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } + + // MARK: - Uncommitted changes + + /// Which side of the working tree an uncommitted change lives on. + public enum GitChangeKind: Sendable { + case staged + case unstaged + case untracked + } + + /// One uncommitted file in the working tree, with its unified diff. + public struct GitChange: Sendable { + public let displayPath: String + public let statusChar: String + public let kind: GitChangeKind + public let unifiedDiff: String + public let truncated: Bool + } + + /// Maximum number of diff lines kept per file before truncation. + private static let maxDiffLines = 800 + + /// Returns every uncommitted change in the working tree at `repoPath`: + /// staged, unstaged, and untracked files, each with its unified diff. A + /// file modified both in the index and the worktree yields two entries + /// (one `.staged`, one `.unstaged`). Returns nil when `repoPath` is not a + /// git repository. + public static func uncommittedChanges(at repoPath: String) async -> [GitChange]? { + guard let statusRaw = await run( + ["status", "--porcelain=v1", "-z"], + at: repoPath + ) else { + return nil + } + + var changes: [GitChange] = [] + // Porcelain `-z` records are NUL-terminated; renames/copies append a + // second NUL-terminated token for the original path. + let tokens = statusRaw.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) + var i = 0 + while i < tokens.count { + let entry = tokens[i] + i += 1 + guard entry.count >= 3 else { continue } + let chars = Array(entry) + let indexChar = chars[0] // staged side + let worktreeChar = chars[1] // worktree side + let displayPath = String(entry.dropFirst(3)) + + let isUntracked = (indexChar == "?" && worktreeChar == "?") + let isRename = indexChar == "R" || worktreeChar == "R" + if isRename, i < tokens.count { + i += 1 // skip the rename's original-path token + } + + if isUntracked { + let diff = await untrackedDiff(displayPath: displayPath, repoPath: repoPath) + changes.append(GitChange( + displayPath: displayPath, + statusChar: "?", + kind: .untracked, + unifiedDiff: diff.text, + truncated: diff.truncated + )) + } else if worktreeChar != " " { + let raw = await run(["diff", "--no-color", "--", displayPath], at: repoPath) ?? "" + let clipped = clipDiff(raw) + changes.append(GitChange( + displayPath: displayPath, + statusChar: String(worktreeChar), + kind: .unstaged, + unifiedDiff: clipped.text, + truncated: clipped.truncated + )) + } + + if !isUntracked, indexChar != " " { + let raw = await run(["diff", "--cached", "--no-color", "--", displayPath], at: repoPath) ?? "" + let clipped = clipDiff(raw) + changes.append(GitChange( + displayPath: displayPath, + statusChar: String(indexChar), + kind: .staged, + unifiedDiff: clipped.text, + truncated: clipped.truncated + )) + } + } + return changes + } + + /// Builds an all-added pseudo-diff for an untracked file by reading its + /// contents. Binary or unreadable files yield an empty diff. + private static func untrackedDiff( + displayPath: String, + repoPath: String + ) async -> (text: String, truncated: Bool) { + let absolute = (repoPath as NSString).appendingPathComponent(displayPath) + guard let content = try? String(contentsOf: URL(fileURLWithPath: absolute), encoding: .utf8) else { + return ("", false) + } + let lines = content.components(separatedBy: "\n") + let capped = lines.count > maxDiffLines + let kept = capped ? Array(lines.prefix(maxDiffLines)) : lines + return (kept.map { "+" + $0 }.joined(separator: "\n"), capped) + } + + /// Clips a unified diff to `maxDiffLines` lines. + private static func clipDiff(_ diff: String) -> (text: String, truncated: Bool) { + let lines = diff.components(separatedBy: "\n") + guard lines.count > maxDiffLines else { return (diff, false) } + return (lines.prefix(maxDiffLines).joined(separator: "\n"), true) + } } #endif diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index b3fd63e..d123603 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -24,6 +24,8 @@ public enum Payload: Sendable { case threadActionRequest(ThreadActionRequestPayload) case loadMoreMessages(LoadMoreMessagesRequestPayload) case moreMessages(MoreMessagesPayload) + case threadChangesRequest(ThreadChangesRequestPayload) + case threadChangesResult(ThreadChangesResultPayload) case searchRequest(SearchRequestPayload) case searchResults(SearchResultsPayload) case notification(NotificationPayload) @@ -68,6 +70,8 @@ public extension Payload { case .threadActionRequest: return "thread_action_request" case .loadMoreMessages: return "load_more_messages" case .moreMessages: return "more_messages" + case .threadChangesRequest: return "thread_changes_request" + case .threadChangesResult: return "thread_changes_result" case .searchRequest: return "search_request" case .searchResults: return "search_results" case .notification: return "notification" @@ -156,19 +160,27 @@ public struct LiveActivityTokenPayload: Codable, Sendable { /// desktop then forgets the activity so the next stream of the same session /// starts a fresh one instead of pushing to a token that no longer renders. public let activityDismissed: Bool? + /// `true` when the foregrounded device started the Live Activity itself + /// with `Activity.request`. Reported the instant the activity is created — + /// well before its per-activity push token, which APNs can take several + /// seconds to mint — so the desktop can cancel its deferred push-to-start + /// and never spawn a duplicate activity. + public let activityStartedLocally: Bool? public init( pushToStartTokenHex: String? = nil, activityTokenHex: String? = nil, activityID: String? = nil, sessionID: String? = nil, - activityDismissed: Bool? = nil + activityDismissed: Bool? = nil, + activityStartedLocally: Bool? = nil ) { self.pushToStartTokenHex = pushToStartTokenHex self.activityTokenHex = activityTokenHex self.activityID = activityID self.sessionID = sessionID self.activityDismissed = activityDismissed + self.activityStartedLocally = activityStartedLocally } } @@ -1241,6 +1253,117 @@ public struct SearchResultsPayload: Codable, Sendable { } } +// MARK: - Thread changes + +/// Mobile-initiated request for the change overview of a thread: every file +/// edited in the thread session plus the project's uncommitted git changes. +/// The desktop is the authoritative source for both (SwiftData edit history and +/// the working tree), so it builds the whole `ThreadChangesResultPayload`. +public struct ThreadChangesRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + + public init(clientRequestID: UUID = UUID(), sessionID: String) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + } +} + +/// One old/new replacement pair. Wire form of `PreviewFile.EditHunk`, which is +/// not itself `Codable`. +public struct SyncEditHunk: Codable, Sendable, Equatable { + public let oldString: String + public let newString: String + + public init(oldString: String, newString: String) { + self.oldString = oldString + self.newString = newString + } +} + +/// Aggregated edits to a single file across a whole thread session. Wire form +/// of `FileEditSummary`. +public struct SyncFileEdit: Codable, Sendable, Identifiable { + public var id: String { path } + public let path: String + public let name: String + /// True if any contributing tool was Write — old content was overwritten. + public let containsWrite: Bool + public let hunks: [SyncEditHunk] + + public init(path: String, name: String, containsWrite: Bool, hunks: [SyncEditHunk]) { + self.path = path + self.name = name + self.containsWrite = containsWrite + self.hunks = hunks + } +} + +/// Which side of the working tree a git change lives on. +public enum SyncGitChangeKind: String, Codable, Sendable { + case staged + case unstaged + case untracked +} + +/// One uncommitted file in the project's working tree, with its unified diff. +public struct SyncGitChange: Codable, Sendable, Identifiable { + public var id: String { "\(kind.rawValue):\(displayPath)" } + /// Path relative to the repository root. + public let displayPath: String + /// Porcelain status letter (M/A/D/R/?/…). + public let statusChar: String + public let kind: SyncGitChangeKind + /// Unified diff text. For untracked files this is an all-added diff. + public let unifiedDiff: String + /// True when `unifiedDiff` was clipped because it exceeded the line cap. + public let truncated: Bool + + public init( + displayPath: String, + statusChar: String, + kind: SyncGitChangeKind, + unifiedDiff: String, + truncated: Bool + ) { + self.displayPath = displayPath + self.statusChar = statusChar + self.kind = kind + self.unifiedDiff = unifiedDiff + self.truncated = truncated + } +} + +/// Desktop reply to a `ThreadChangesRequestPayload`: the two datasets backing +/// the mobile "View Changes" sheet. +public struct ThreadChangesResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// False when the request could not be served (e.g. not a git repository). + public let ok: Bool + public let errorMessage: String? + /// Every file edited in the thread session. + public let turnEdits: [SyncFileEdit] + /// Uncommitted git changes in the session's project. + public let uncommitted: [SyncGitChange] + + public init( + clientRequestID: UUID, + sessionID: String, + ok: Bool, + errorMessage: String? = nil, + turnEdits: [SyncFileEdit], + uncommitted: [SyncGitChange] + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.ok = ok + self.errorMessage = errorMessage + self.turnEdits = turnEdits + self.uncommitted = uncommitted + } +} + public struct NotificationPayload: Codable, Sendable { public enum Kind: String, Codable, Sendable { case responseComplete @@ -1444,6 +1567,8 @@ extension Payload: Codable { case threadActionRequest = "thread_action_request" case loadMoreMessages = "load_more_messages" case moreMessages = "more_messages" + case threadChangesRequest = "thread_changes_request" + case threadChangesResult = "thread_changes_result" case searchRequest = "search_request" case searchResults = "search_results" case notification @@ -1492,6 +1617,8 @@ extension Payload: Codable { case .threadActionRequest: self = .threadActionRequest(try container.decode(ThreadActionRequestPayload.self, forKey: .data)) case .loadMoreMessages: self = .loadMoreMessages(try container.decode(LoadMoreMessagesRequestPayload.self, forKey: .data)) case .moreMessages: self = .moreMessages(try container.decode(MoreMessagesPayload.self, forKey: .data)) + case .threadChangesRequest: self = .threadChangesRequest(try container.decode(ThreadChangesRequestPayload.self, forKey: .data)) + case .threadChangesResult: self = .threadChangesResult(try container.decode(ThreadChangesResultPayload.self, forKey: .data)) case .searchRequest: self = .searchRequest(try container.decode(SearchRequestPayload.self, forKey: .data)) case .searchResults: self = .searchResults(try container.decode(SearchResultsPayload.self, forKey: .data)) case .notification: self = .notification(try container.decode(NotificationPayload.self, forKey: .data)) @@ -1536,6 +1663,8 @@ extension Payload: Codable { case .threadActionRequest(let p): try container.encode(TypeKey.threadActionRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .loadMoreMessages(let p): try container.encode(TypeKey.loadMoreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .moreMessages(let p): try container.encode(TypeKey.moreMessages.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .threadChangesRequest(let p): try container.encode(TypeKey.threadChangesRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .threadChangesResult(let p): try container.encode(TypeKey.threadChangesResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .searchRequest(let p): try container.encode(TypeKey.searchRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .searchResults(let p): try container.encode(TypeKey.searchResults.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .notification(let p): try container.encode(TypeKey.notification.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/README.md b/README.md index 22e59d7..ecc3f96 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Same agents, no terminal required. | **Git Status** | Sidebar Git status summary with changed-file counts, branch display, and local/remote branch switching. | | **GitHub Integration** | OAuth device flow, Keychain token storage, SSH key management, repository browsing, and cloning. | | **Memo Panel** | Per-project rich-text memo pad with headings, lists, checkboxes, links, and persistent storage. | -| **Skill Marketplace** | Browse and install agent skills from Settings, refreshed with a 5-minute cache and enabled for supported coding agents. | +| **Skill Marketplace** | Browse and install OpenAI Agent Skills and compatible skill catalogs from Settings, refreshed with a 5-minute cache and enabled for supported coding agents. | | **Themes and Font Controls** | Six accent themes plus independent font size controls for the interface and message area. | | **Focus Mode** | Optional focused chat layout that can be enabled from Settings. | | **Notifications** | Optional system notifications with response previews while RxCode is in the background. | diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 9521837..2e51531 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1205,6 +1205,20 @@ final class AppState { } mobileSyncObservers.append(searchObserver) + let threadChangesObserver = center.addObserver( + forName: .mobileSyncThreadChangesRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ThreadChangesRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileThreadChangesRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(threadChangesObserver) + let branchOpObserver = center.addObserver( forName: .mobileSyncBranchOpRequested, object: nil, @@ -2766,6 +2780,74 @@ final class AppState { ) } + /// Builds the change overview for the mobile "View Changes" sheet: every + /// file edited in the thread session plus the project's uncommitted git + /// changes. Replies with a `threadChangesResult`. + private func handleMobileThreadChangesRequest( + _ request: ThreadChangesRequestPayload, + fromHex hex: String + ) async { + let resolvedID = resolveCurrentSessionId(request.sessionID) + + // This Turn: every file edited in the thread session (SwiftData history). + let turnEdits = threadStore.fetchFileEdits(sessionId: resolvedID).map { edit -> SyncFileEdit in + let summary = edit.toSummary() + return SyncFileEdit( + path: summary.path, + name: summary.name, + containsWrite: summary.containsWrite, + hunks: summary.hunks.map { + SyncEditHunk(oldString: $0.oldString, newString: $0.newString) + } + ) + } + + func reply(ok: Bool, error: String?, uncommitted: [SyncGitChange]) async { + await MobileSyncService.shared.send( + .threadChangesResult(ThreadChangesResultPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + ok: ok, + errorMessage: error, + turnEdits: turnEdits, + uncommitted: uncommitted + )), + toHex: hex + ) + } + + // Uncommitted: the session's project working tree. + let projectPath = allSessionSummaries + .first(where: { $0.id == resolvedID }) + .flatMap { summary in projects.first(where: { $0.id == summary.projectId })?.path } + + guard let projectPath, !projectPath.isEmpty else { + await reply(ok: false, error: "This thread has no associated project folder.", uncommitted: []) + return + } + + guard let gitChanges = await GitHelper.uncommittedChanges(at: projectPath) else { + await reply(ok: false, error: "This project is not a git repository.", uncommitted: []) + return + } + + let uncommitted = gitChanges.map { change -> SyncGitChange in + let kind: SyncGitChangeKind = switch change.kind { + case .staged: .staged + case .unstaged: .unstaged + case .untracked: .untracked + } + return SyncGitChange( + displayPath: change.displayPath, + statusChar: change.statusChar, + kind: kind, + unifiedDiff: change.unifiedDiff, + truncated: change.truncated + ) + } + await reply(ok: true, error: nil, uncommitted: uncommitted) + } + /// User-triggered full reindex of every thread. Wipes cached embeddings, /// then re-embeds every thread. Updates `reindexProgress` so the UI can /// render a counter. diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index eccc3a4..38b8424 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -20,6 +20,7 @@ actor MarketplaceService { ("anthropics", "skills", "agent-skills"), ("anthropics", "knowledge-work-plugins", "knowledge-work"), ("anthropics", "financial-services-plugins", "financial-services"), + ("rxtech-lab", "agent-skills", "rxlab-skills"), ] private static let openAISkillsSource = MarketplaceSource(owner: "openai", repo: "skills") @@ -233,19 +234,48 @@ actor MarketplaceService { do { var config = try loadConfig() var changed = false - let existingIds = Set(config.plugins.map(\.id)) - - for plugin in catalog where installedNames.contains(plugin.name) && !existingIds.contains(plugin.id) { - config.plugins.append(MarketplacePluginRecord( - name: plugin.name, - marketplace: plugin.marketplace, - summary: plugin.description, - category: plugin.category, - marketplaceSource: plugin.marketplaceSource, - sourceType: plugin.sourceType, - skillPaths: plugin.skillPaths - )) - changed = true + for plugin in catalog where installedNames.contains(plugin.name) { + if let index = config.plugins.firstIndex(where: { $0.id == plugin.id }) { + var shouldFetchInstructions = config.plugins[index].instructions == nil + if config.plugins[index].summary != plugin.description { + config.plugins[index].summary = plugin.description + changed = true + } + if config.plugins[index].category != plugin.category { + config.plugins[index].category = plugin.category + changed = true + } + if config.plugins[index].marketplaceSource != plugin.marketplaceSource { + config.plugins[index].marketplaceSource = plugin.marketplaceSource + changed = true + } + if config.plugins[index].sourceType != plugin.sourceType { + config.plugins[index].sourceType = plugin.sourceType + changed = true + } + if config.plugins[index].skillPaths != plugin.skillPaths { + config.plugins[index].skillPaths = plugin.skillPaths + shouldFetchInstructions = true + changed = true + } + if shouldFetchInstructions, + let instructions = await skillInstructions(for: plugin) { + config.plugins[index].instructions = instructions + changed = true + } + } else { + config.plugins.append(MarketplacePluginRecord( + name: plugin.name, + marketplace: plugin.marketplace, + summary: plugin.description, + category: plugin.category, + marketplaceSource: plugin.marketplaceSource, + sourceType: plugin.sourceType, + skillPaths: plugin.skillPaths, + instructions: await skillInstructions(for: plugin) + )) + changed = true + } } if changed { @@ -321,7 +351,8 @@ actor MarketplaceService { !emittedMarketplaces.contains(record.marketplace) { emittedMarketplaces.insert(record.marketplace) let marketplaceKey = "marketplaces.\(tomlKey(record.marketplace))" - pairs += ["-c", "\(marketplaceKey).source_type=\(tomlString("github"))"] + // Codex's `source_type` enum only accepts `git` or `local`. + pairs += ["-c", "\(marketplaceKey).source_type=\(tomlString("git"))"] pairs += ["-c", "\(marketplaceKey).source=\(tomlString(source.codexSource))"] } diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 25a5970..b7d92c2 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -102,13 +102,19 @@ final class MobileSyncService: ObservableObject { /// Session ids currently streaming — the live job count for the widget. private var streamingSessionIDs: Set = [] - /// Per-job Live Activity bookkeeping, keyed by session id. - private var jobActivityState: [String: JobActivityState] = [:] - /// Pending delayed push-to-start tasks, keyed by session id. The - /// push-to-start is deferred briefly so a foregrounded device can instead - /// start the activity locally; the task is cancelled once that device - /// reports a per-activity token. - private var pendingStartTasks: [String: Task] = [:] + /// Every job tracked by the single aggregate Live Activity: those still + /// running plus recently finished ones, in start order. + private var trackedJobs: [JobContent] = [] + /// `true` once a foregrounded device reported it started the activity + /// locally; suppresses the push-to-start until the activity goes away. + private var jobsActivityLocallyStarted = false + /// Signature of the content-state last pushed, so an update only fires on + /// a real change rather than on every session event. + private var lastPushedJobsSignature = "" + /// Pending deferred push-to-start. The push-to-start is delayed briefly so + /// a foregrounded device can start the activity locally instead; this task + /// is cancelled once a device reports it did. + private var pendingStartTask: Task? /// Last widget job count pushed, so a widget push only fires on a change. private var lastWidgetJobCount: Int = -1 @@ -451,8 +457,8 @@ final class MobileSyncService: ObservableObject { // MARK: - Live Activity & widget push - /// Fold a session update into the streaming-job set and the per-job Live - /// Activity state, then push any resulting Live Activity / widget changes. + /// Fold a session update into the streaming-job set and the aggregate Live + /// Activity, then push any resulting Live Activity / widget changes. /// Called for every `broadcastSessionUpdate`. private func updateJobTracking( sessionID: String, @@ -472,59 +478,43 @@ final class MobileSyncService: ObservableObject { } // Summaries carry title/progress/todos — they drive the Live Activity. if let summary { - reconcileJobActivity(summary: summary) + foldSummaryIntoJobs(summary) + pushJobsActivity() } pushWidgetUpdateIfJobCountChanged() } - /// Drive a job's Live Activity from the latest summary. + /// Merge one session summary into `trackedJobs`. /// - /// An activity is created once with a push-to-start, then reused for the - /// lifetime of the session: it flips between "running" and "done" via - /// update pushes and is never ended or auto-dismissed by the desktop. This - /// keeps re-runs of the same thread off the scarce iOS push-to-start - /// budget; the user dismisses the activity when they are done with it. - private func reconcileJobActivity(summary: RxCodeSync.SessionSummary) { - let sessionID = summary.id + /// A running session is inserted or updated. A finished session updates + /// the job only if it is already tracked, and is otherwise ignored — the + /// aggregate activity follows jobs it saw start. When a new job begins + /// while every tracked job is already done, the previous (acknowledged) + /// batch is cleared so the activity starts a fresh list. + private func foldSummaryIntoJobs(_ summary: RxCodeSync.SessionSummary) { let content = makeJobContent(from: summary) - if summary.isStreaming { - if var state = jobActivityState[sessionID] { - // The activity already exists — reuse it. Flip it back to - // "running" if the job had finished, and push an update - // whenever the rendered state changes. - let contentChanged = state.lastContent.signature != content.signature - let resumed = state.isDone - state.lastContent = content - state.isDone = false - jobActivityState[sessionID] = state - if contentChanged || resumed { - logger.info("[LiveActivity] reconcile decision=update session=\(sessionID, privacy: .public) resumed=\(resumed, privacy: .public) todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public)") - sendLiveActivityUpdate(content, phase: "running") - } else { - logger.debug("[LiveActivity] reconcile decision=skip-unchanged session=\(sessionID, privacy: .public)") - } - } else { - jobActivityState[sessionID] = JobActivityState(lastContent: content) - logger.info("[LiveActivity] reconcile decision=start session=\(sessionID, privacy: .public)") - scheduleLiveActivityStart(for: sessionID) + if let idx = trackedJobs.firstIndex(where: { $0.sessionID == summary.id }) { + trackedJobs[idx] = content + } else if summary.isStreaming { + if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { + trackedJobs.removeAll() + lastPushedJobsSignature = "" } - } else if var state = jobActivityState[sessionID] { - // The job finished. Keep the activity alive in the "done" state so - // it can be reused if the session streams again; the user dismisses - // it manually. We never end or auto-dismiss it ourselves. - guard !state.isDone else { - logger.debug("[LiveActivity] reconcile decision=skip-already-done session=\(sessionID, privacy: .public)") - return + trackedJobs.append(content) + } + pruneTrackedJobs() + } + + /// Cap the tracked-job list, dropping the oldest finished jobs first so a + /// long-lived device never accumulates an unbounded history. + private func pruneTrackedJobs() { + let cap = 6 + while trackedJobs.count > cap { + if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { + trackedJobs.remove(at: doneIdx) + } else { + trackedJobs.removeFirst() } - // The job is already finished — no need to push-to-start it. - cancelPendingStart(sessionID) - state.isDone = true - state.lastContent = content - jobActivityState[sessionID] = state - logger.info("[LiveActivity] reconcile decision=done session=\(sessionID, privacy: .public)") - sendLiveActivityUpdate(content, phase: "done", staleAfter: 8 * 3600) - } else { - logger.debug("[LiveActivity] reconcile decision=ignore session=\(sessionID, privacy: .public) — not streaming and no active activity") } } @@ -535,71 +525,112 @@ final class MobileSyncService: ObservableObject { projectName: projectNameResolver?(summary.projectId) ?? "", todoDone: summary.progress?.done ?? 0, todoTotal: summary.progress?.total ?? 0, - currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm + currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm, + isDone: !summary.isStreaming ) } - /// Schedule the push-to-start after a short delay. A foregrounded mobile - /// starts the activity itself (no push-to-start budget) and reports its - /// token within a second or two; that token cancels this task. Only a - /// backgrounded device — which cannot start an activity on its own — ends - /// up actually receiving the push-to-start. - private func scheduleLiveActivityStart(for sessionID: String) { - pendingStartTasks[sessionID]?.cancel() - pendingStartTasks[sessionID] = Task { @MainActor [weak self] in + // MARK: Aggregate Live Activity + + /// Concatenated per-job signatures — identifies a distinct rendered state. + private var jobsSignature: String { + trackedJobs.map(\.signature).joined(separator: ";") + } + + /// `true` once every tracked job has finished. + private var allJobsDone: Bool { + !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) + } + + /// `true` when some paired device has registered the aggregate activity's + /// update token — i.e. the activity exists and can be pushed `update`s. + private var hasAnyActivityToken: Bool { + pairedDevices.contains { !($0.liveActivityTokens ?? []).isEmpty } + } + + /// Drive the single aggregate Live Activity from `trackedJobs`. + /// + /// The activity is created once with a push-to-start and then reused for + /// the lifetime of the device session: it is never ended or auto-dismissed + /// by the desktop, only updated. One activity for every job keeps re-runs + /// off the scarce iOS push-to-start budget; the user dismisses it. + private func pushJobsActivity() { + guard !trackedJobs.isEmpty else { return } + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 + if hasAnyActivityToken { + let signature = jobsSignature + guard signature != lastPushedJobsSignature else { + logger.debug("[LiveActivity] jobs activity unchanged — skip update") + return + } + lastPushedJobsSignature = signature + logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") + sendJobsActivityUpdate(staleAfter: staleAfter) + } else if jobsActivityLocallyStarted { + // The activity exists locally; its update token has not been + // minted yet. The first push goes out when that token registers. + logger.debug("[LiveActivity] jobs activity started locally — awaiting update token") + } else { + scheduleJobsActivityStart() + } + } + + /// Schedule the push-to-start after a short delay. A foregrounded device + /// starts the activity itself (no push-to-start budget) and reports it + /// within a second or two, cancelling this task. Only a backgrounded + /// device ends up actually receiving the push-to-start. + private func scheduleJobsActivityStart() { + guard pendingStartTask == nil else { return } + logger.info("[LiveActivity] scheduling jobs activity push-to-start in 5s") + pendingStartTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(5)) guard !Task.isCancelled, let self else { return } - self.pendingStartTasks.removeValue(forKey: sessionID) - guard let job = self.jobActivityState[sessionID], !job.isDone else { return } - let hasToken = self.pairedDevices.contains { device in - (device.liveActivityTokens ?? []).contains { $0.sessionID == sessionID } - } - guard !hasToken else { - self.logger.info("[LiveActivity] push-to-start skipped session=\(sessionID, privacy: .public) — device started the activity locally") + self.pendingStartTask = nil + guard !self.trackedJobs.isEmpty else { return } + guard !self.hasAnyActivityToken, !self.jobsActivityLocallyStarted else { + self.logger.info("[LiveActivity] push-to-start skipped — a device already has the activity") return } - self.logger.info("[LiveActivity] push-to-start firing session=\(sessionID, privacy: .public) — no local activity was reported") - self.sendLiveActivityStart(job.lastContent) + self.sendJobsActivityStart() } } - /// Cancel a pending push-to-start — the activity already exists, or the - /// job ended before the delay elapsed. - private func cancelPendingStart(_ sessionID: String) { - if let task = pendingStartTasks.removeValue(forKey: sessionID) { - task.cancel() - logger.debug("[LiveActivity] pending push-to-start cancelled session=\(sessionID, privacy: .public)") + /// Cancel a pending push-to-start — the activity already exists. + private func cancelJobsActivityStart() { + if pendingStartTask != nil { + pendingStartTask?.cancel() + pendingStartTask = nil + logger.debug("[LiveActivity] pending push-to-start cancelled") } } - /// Push a `start` Live Activity to every device with a push-to-start token. - private func sendLiveActivityStart(_ content: JobContent) { + /// Push a `start` for the aggregate activity to every device with a + /// push-to-start token. + private func sendJobsActivityStart() { let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } guard !devices.isEmpty else { - logger.warning("[LiveActivity] start skipped session=\(content.sessionID, privacy: .public) — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public)); the mobile app must register one first") + logger.warning("[LiveActivity] start skipped — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public))") return } guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[LiveActivity] start skipped session=\(content.sessionID, privacy: .public) — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + logger.error("[LiveActivity] start skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") return } let now = Date() + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 let payload: [String: Any] = ["aps": [ "timestamp": Int(now.timeIntervalSince1970), "event": "start", - "content-state": contentStateDict(content, phase: "running", at: now), + "content-state": jobsContentStateDict(at: now), "attributes-type": "RxCodeJobActivityAttributes", - "attributes": [ - "sessionID": content.sessionID, - "projectName": content.projectName, - "title": content.title, - ], - "stale-date": Int(now.addingTimeInterval(3600).timeIntervalSince1970), + "attributes": [String: Any](), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), ]] - logger.info("[LiveActivity] start session=\(content.sessionID, privacy: .public) devices=\(devices.count, privacy: .public) project=\(content.projectName, privacy: .public) title=\(content.title, privacy: .public) phase=running todos=\(content.todoDone, privacy: .public)/\(content.todoTotal, privacy: .public) step=\(content.currentStep ?? "", privacy: .public)") + lastPushedJobsSignature = jobsSignature + logger.info("[LiveActivity] start jobs activity devices=\(devices.count, privacy: .public) jobs=\(self.trackedJobs.count, privacy: .public)") for device in devices { guard let token = device.liveActivityStartToken else { continue } - logger.info("[LiveActivity] start → posting push session=\(content.sessionID, privacy: .public) startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + logger.info("[LiveActivity] start → posting push startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") Task { await postRawPush(deviceToken: token, pushType: "liveactivity", apnsPayload: payload, collapseID: nil, device: device, pushURL: pushURL) @@ -607,58 +638,61 @@ final class MobileSyncService: ObservableObject { } } - /// Push an `update` to a job's activity. `staleAfter` sets when the - /// activity dims to its stale presentation — long for the terminal "done" - /// state, which stays on screen until the user dismisses it. No `end` or - /// `dismissal-date` is ever sent: the desktop keeps the activity alive and - /// only the user dismisses it. - private func sendLiveActivityUpdate(_ content: JobContent, phase: String, staleAfter: TimeInterval = 3600) { + /// Push an `update` for the aggregate activity. `staleAfter` sets when the + /// activity dims — long for the terminal all-done state, which stays on + /// screen until the user dismisses it. No `end` is ever sent. + private func sendJobsActivityUpdate(staleAfter: TimeInterval) { let now = Date() let payload: [String: Any] = ["aps": [ "timestamp": Int(now.timeIntervalSince1970), "event": "update", - "content-state": contentStateDict(content, phase: phase, at: now), + "content-state": jobsContentStateDict(at: now), "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), ]] - pushToActivityTokens(sessionID: content.sessionID, payload: payload) + pushToActivityTokens(payload: payload) } /// Build the ActivityKit `content-state` dict. Field names mirror /// `RxCodeJobActivityAttributes.ContentState` in the widget target. - private func contentStateDict(_ content: JobContent, phase: String, at date: Date) -> [String: Any] { - var dict: [String: Any] = [ - "phase": phase, - "todoDone": content.todoDone, - "todoTotal": content.todoTotal, - "updatedAt": date.timeIntervalSince1970, - ] - if let step = content.currentStep, !step.isEmpty { - dict["currentStep"] = step + private func jobsContentStateDict(at date: Date) -> [String: Any] { + let jobs: [[String: Any]] = trackedJobs.map { job in + var dict: [String: Any] = [ + "id": job.sessionID, + "phase": job.isDone ? "done" : "running", + "title": job.title, + "projectName": job.projectName, + "todoDone": job.todoDone, + "todoTotal": job.todoTotal, + ] + if let step = job.currentStep, !step.isEmpty { + dict["currentStep"] = step + } + return dict } - return dict + return ["jobs": jobs, "updatedAt": date.timeIntervalSince1970] } - /// Push a Live Activity payload to every per-activity token bound to a job. - private func pushToActivityTokens(sessionID: String, payload: [String: Any]) { + /// Push a Live Activity payload to every registered aggregate-activity + /// token (one per paired device). + private func pushToActivityTokens(payload: [String: Any]) { guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[LiveActivity] update/end skipped session=\(sessionID, privacy: .public) — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + logger.error("[LiveActivity] update skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") return } - let collapseID = "rxcode-la-\(sessionID.prefix(40))" var matched = 0 for device in pairedDevices { - for ref in (device.liveActivityTokens ?? []) where ref.sessionID == sessionID { + for ref in (device.liveActivityTokens ?? []) { matched += 1 - logger.info("[LiveActivity] push → activity token session=\(sessionID, privacy: .public) activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + logger.info("[LiveActivity] push → activity token activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") Task { await postRawPush(deviceToken: ref.token, pushType: "liveactivity", - apnsPayload: payload, collapseID: collapseID, + apnsPayload: payload, collapseID: "rxcode-jobs-activity", device: device, pushURL: pushURL) } } } if matched == 0 { - logger.warning("[LiveActivity] update/end has no registered per-activity token session=\(sessionID, privacy: .public) — the mobile never reported one, so push-to-start likely failed to spawn an Activity on the device") + logger.warning("[LiveActivity] update has no registered activity token — the mobile never reported one") } } @@ -784,47 +818,40 @@ final class MobileSyncService: ObservableObject { logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") } if t.activityDismissed == true { - // The user swiped the Live Activity away. Forget the activity - // and its token so the next stream of this session starts a - // fresh one instead of pushing to a token that no longer - // renders anything. - let sessionID = t.sessionID ?? "" - if !sessionID.isEmpty { - jobActivityState.removeValue(forKey: sessionID) - cancelPendingStart(sessionID) - } + // The user swiped the aggregate Live Activity away. Forget + // this device's update token; once no device tracks the + // activity the next job push-to-starts a fresh one. if var refs = pairedDevices[idx].liveActivityTokens { - refs.removeAll { - $0.activityID == t.activityID || (!sessionID.isEmpty && $0.sessionID == sessionID) - } - pairedDevices[idx].liveActivityTokens = refs + refs.removeAll { t.activityID == nil || $0.activityID == t.activityID } + pairedDevices[idx].liveActivityTokens = refs.isEmpty ? nil : refs } - logger.info("[LiveActivity] activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) session=\(sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + if !hasAnyActivityToken { + jobsActivityLocallyStarted = false + lastPushedJobsSignature = "" + cancelJobsActivityStart() + } + logger.info("[LiveActivity] aggregate activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } else if t.activityStartedLocally == true { + // A foregrounded device started the activity itself with + // `Activity.request` and reported it the instant it was + // created — long before APNs mints the update token. Cancel + // the deferred push-to-start so iOS never spawns a duplicate. + jobsActivityLocallyStarted = true + cancelJobsActivityStart() + logger.info("[LiveActivity] device started aggregate activity locally mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) — deferred push-to-start cancelled") } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, let activityID = t.activityID { - let ref = LiveActivityTokenRef( - activityID: activityID, - sessionID: t.sessionID ?? "", - token: activityToken - ) - var refs = pairedDevices[idx].liveActivityTokens ?? [] - // One activity per session — drop any prior ref for the same - // activity or session so stale tokens don't accumulate. - refs.removeAll { - $0.activityID == activityID || (!ref.sessionID.isEmpty && $0.sessionID == ref.sessionID) - } - refs.append(ref) - pairedDevices[idx].liveActivityTokens = refs - logger.info("[LiveActivity] activity token registered activity=\(activityID, privacy: .public) session=\(ref.sessionID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - // A token means the activity exists — drop any pending - // push-to-start so a second activity is never created. - if !ref.sessionID.isEmpty { - cancelPendingStart(ref.sessionID) - } + // One aggregate activity per device — replace any prior token. + pairedDevices[idx].liveActivityTokens = [ + LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) + ] + logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + cancelJobsActivityStart() // Push the latest known state straight away so a freshly // started activity isn't left blank until the next change. - if !ref.sessionID.isEmpty, let st = jobActivityState[ref.sessionID] { - sendLiveActivityUpdate(st.lastContent, phase: st.isDone ? "done" : "running") + if !trackedJobs.isEmpty { + lastPushedJobsSignature = jobsSignature + sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) } } pairedDevices[idx].lastSeen = .now @@ -897,6 +924,13 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .threadChangesRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_changes_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncThreadChangesRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .subscribeSession(let sub): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "subscribe_session") else { return } subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" @@ -1121,7 +1155,8 @@ private struct APNsPushResponse: Codable { } } -/// Latest content the desktop knows for one job's Live Activity. +/// Latest content the desktop knows for one job in the aggregate Live +/// Activity. Stored in `MobileSyncService.trackedJobs` in start order. private struct JobContent { var sessionID: String var title: String @@ -1129,19 +1164,17 @@ private struct JobContent { var todoDone: Int var todoTotal: Int var currentStep: String? - - /// Identifies a distinct rendered state, so an update only pushes on a - /// real change rather than on every session event. - var signature: String { "\(todoDone)/\(todoTotal)|\(currentStep ?? "")" } -} - -/// Per-job Live Activity bookkeeping, keyed by session id in `MobileSyncService`. -private struct JobActivityState { - var lastContent: JobContent - /// `true` once the job has finished and its activity shows the terminal - /// "done" state. Kept so a re-stream of the same session reuses the - /// activity instead of issuing another budget-limited push-to-start. - var isDone: Bool = false + /// `true` once the job has finished. It shows the "done" phase but stays + /// in the aggregate list so the activity can report the completed batch. + var isDone: Bool + + /// Identifies a distinct rendered state for one job, so an update only + /// pushes on a real change rather than on every session event. Includes + /// `title` so the activity refreshes when the desktop swaps in an + /// AI-summarized title. + var signature: String { + "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" + } } extension Notification.Name { @@ -1153,6 +1186,7 @@ extension Notification.Name { static let mobileSyncThreadActionRequested = Notification.Name("mobileSync.threadActionRequested") static let mobileSyncLoadMoreMessagesRequested = Notification.Name("mobileSync.loadMoreMessagesRequested") static let mobileSyncSearchRequested = Notification.Name("mobileSync.searchRequested") + static let mobileSyncThreadChangesRequested = Notification.Name("mobileSync.threadChangesRequested") static let mobileSyncSettingsUpdateReceived = Notification.Name("mobileSync.settingsUpdateReceived") static let mobileSyncPermissionResponse = Notification.Name("mobileSync.permissionResponse") static let mobileSyncQuestionAnswerReceived = Notification.Name("mobileSync.questionAnswerReceived") diff --git a/RxCode/Views/Chat/SkillMarketView.swift b/RxCode/Views/Chat/SkillMarketView.swift index 77fc5fc..97355c3 100644 --- a/RxCode/Views/Chat/SkillMarketView.swift +++ b/RxCode/Views/Chat/SkillMarketView.swift @@ -1,5 +1,5 @@ -import SwiftUI import RxCodeCore +import SwiftUI /// Skill marketplace panel — displayed as an overlay or embedded in a settings tab. struct SkillMarketView: View { @@ -198,9 +198,9 @@ struct SkillMarketView: View { let query = searchText.lowercased() plugins = plugins.filter { $0.name.lowercased().contains(query) || - $0.description.lowercased().contains(query) || - $0.author.lowercased().contains(query) || - $0.category.lowercased().contains(query) + $0.description.lowercased().contains(query) || + $0.author.lowercased().contains(query) || + $0.category.lowercased().contains(query) } } @@ -422,7 +422,7 @@ struct PluginDetailView: View { Text("Agent Availability") .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - Text("Installed skills are managed by RxCode and enabled for Claude Code, Codex, and ACP agents where supported.") + Text("Installed skills are managed by RxCode, sourced from OpenAI Agent Skills and compatible catalogs, and enabled for Claude Code, Codex, and ACP agents where supported.") .font(.system(size: ClaudeTheme.size(12))) .foregroundStyle(.secondary) .padding(10) @@ -457,10 +457,6 @@ struct PluginDetailView: View { } .font(.system(size: ClaudeTheme.size(12), weight: .medium)) .foregroundStyle(Color.red) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(Color.red.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } @ViewBuilder @@ -492,7 +488,6 @@ struct PluginDetailView: View { } } - #Preview { SkillMarketView() .environment(AppState()) diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index fa3bc73..058a8e9 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -539,7 +539,7 @@ enum ManualTopic: String, CaseIterable, Identifiable { [ ManualSection( title: "Skill Marketplace", - body: "Open Settings and select Skill Marketplace to browse agent skills from configured catalogs. Skills can be filtered by category or searched by name, description, or author." + body: "Open Settings and select Skill Marketplace to browse OpenAI Agent Skills and compatible skill catalogs. Skills can be filtered by category or searched by name, description, or author." ), ManualSection( title: "Installing Skills", diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index f255c51..2003c74 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -108,6 +108,13 @@ final class MobileAppState: ObservableObject { private var pendingSearchID: UUID? private var searchDebounceTask: Task? + /// Backing data for the thread "View Changes" sheet — thread file edits and + /// uncommitted git changes for one thread. Nil until first loaded; carries + /// its own `sessionID` so a stale result for another thread is ignored. + @Published var threadChanges: ThreadChangesResultPayload? + @Published var isLoadingThreadChanges: Bool = false + private var pendingThreadChangesID: UUID? + @Published var remoteFolderRoot: RemoteFolderNode? @Published var remoteFolderIsLoading = false @Published var remoteFolderError: String? @@ -671,6 +678,25 @@ final class MobileAppState: ObservableObject { } } + /// Requests the change overview (thread file edits + uncommitted git + /// changes) for `sessionID` from the desktop. The reply lands in + /// `threadChanges` via the `threadChangesResult` payload. + func requestThreadChanges(sessionID: String) async { + guard isPaired else { return } + let requestID = UUID() + pendingThreadChangesID = requestID + isLoadingThreadChanges = true + let payload = ThreadChangesRequestPayload(clientRequestID: requestID, sessionID: sessionID) + do { + try await client.send(.threadChangesRequest(payload), toHex: pairedDesktopPubkey) + } catch { + if pendingThreadChangesID == requestID { + pendingThreadChangesID = nil + isLoadingThreadChanges = false + } + } + } + func respondToPermission(allow: Bool, denyReason: String? = nil) async { guard let pending = pendingPermission else { return } let payload = PermissionResponsePayload( @@ -880,7 +906,7 @@ final class MobileAppState: ObservableObject { for desktop in pairedDesktops { do { try await client.send(.liveActivityToken(payload), toHex: desktop.pubkeyHex) - logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") + logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) startedLocally=\(payload.activityStartedLocally == true, privacy: .public) dismissed=\(payload.activityDismissed == true, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") } catch { logger.error("[LiveActivity] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") } @@ -1106,6 +1132,12 @@ final class MobileAppState: ObservableObject { searchProjectIDs = results.projectIDs searchThreadHits = results.threadHits isSearching = false + case .threadChangesResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "thread_changes_result") else { return } + guard let pending = pendingThreadChangesID, result.clientRequestID == pending else { return } + pendingThreadChangesID = nil + isLoadingThreadChanges = false + threadChanges = result case .branchOpResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "branch_op_result") else { return } inFlightBranchOps.remove(result.clientRequestID) diff --git a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift index eadc7a1..9e1d4fc 100644 --- a/RxCodeMobile/State/MobileLiveActivityCoordinator.swift +++ b/RxCodeMobile/State/MobileLiveActivityCoordinator.swift @@ -2,18 +2,23 @@ // MobileLiveActivityCoordinator.swift // RxCodeMobile // -// Owns the iOS side of the job Live Activity push lifecycle. +// Owns the iOS side of the aggregate job Live Activity push lifecycle. // -// The paired desktop is what actually starts, updates, and ends Live -// Activities — it does so over APNs (see `MobileSyncService`). This -// coordinator's only job is to harvest the two kinds of ActivityKit push -// token and forward them to the desktop: +// RxCode shows a *single* Live Activity per device that aggregates every +// in-progress agent job. One activity — no matter how many jobs run — keeps +// the app well within the scarce iOS push-to-start budget. +// +// The paired desktop is what actually starts, updates, and ends the activity +// over APNs (see `MobileSyncService`). This coordinator harvests the two +// kinds of ActivityKit push token and forwards them to the desktop: // // 1. `Activity.pushToStartTokenUpdates` — the device-wide push-to-start -// token (iOS 17.2+) that lets the desktop spawn an activity remotely -// when a new job begins. -// 2. Each activity's `pushTokenUpdates` — the per-activity update token the -// desktop targets for `update` and `end` pushes. +// token (iOS 17.2+) that lets the desktop spawn the activity remotely. +// 2. The activity's `pushTokenUpdates` — the update token the desktop +// targets for `update` pushes. +// +// While the app is foregrounded the activity is instead started locally with +// `Activity.request`, which spends no push-to-start budget. // #if os(iOS) @@ -32,12 +37,17 @@ final class MobileLiveActivityCoordinator { /// Latest device-wide push-to-start token. private var startTokenHex: String? - /// Per-activity update tokens, keyed by `Activity.id`. - private var activityTokens: [String: (sessionID: String, tokenHex: String)] = [:] - /// Sessions for which we started a Live Activity locally (foreground path). - /// Tracked so a burst of session updates can't start the same one twice; - /// cleared when the user dismisses the activity. - private var locallyStartedSessions: Set = [] + /// `Activity.id` of the single aggregate activity, once it exists. + private var currentActivityID: String? + /// Latest per-activity update token for the aggregate activity. + private var activityTokenHex: String? + /// `true` once this app started the aggregate activity locally; cleared + /// when the activity is dismissed. + private var locallyStarted = false + /// Activity ids this coordinator is ending on purpose to clear a + /// duplicate. Their `.ended` state must not be reported to the desktop as + /// a user dismissal — the device keeps its surviving activity. + private var intentionallyEndedActivityIDs: Set = [] private var observationStarted = false private var cancellables = Set() @@ -57,14 +67,14 @@ final class MobileLiveActivityCoordinator { } } .store(in: &cancellables) - // When the app is in the foreground a job's Live Activity can be - // started locally with `Activity.request` — no push-to-start budget. - // Watch the mirrored session list and start one as soon as a job - // begins streaming; backgrounded jobs still use the desktop's push. + // When the app is in the foreground the activity can be started + // locally with `Activity.request` — no push-to-start budget. Watch the + // mirrored session list and start it as soon as the first job begins; + // backgrounded jobs still rely on the desktop's push-to-start. state.$sessions .receive(on: DispatchQueue.main) .sink { [weak self] sessions in - self?.startLocalActivitiesIfForeground(for: sessions) + self?.startActivityIfNeeded(for: sessions) } .store(in: &cancellables) } @@ -95,36 +105,39 @@ final class MobileLiveActivityCoordinator { } } } else { - logger.warning("[LiveActivity] iOS < 17.2 — push-to-start unavailable; the desktop cannot spawn an activity remotely") + logger.warning("[LiveActivity] iOS < 17.2 — push-to-start unavailable; the desktop cannot spawn the activity remotely") } - // Re-attach to activities already running (e.g. after a relaunch), + // Re-attach to an activity already running (e.g. after a relaunch), // then pick up future ones as ActivityKit reports them. let existing = Activity.activities logger.info("[LiveActivity] re-attaching to \(existing.count, privacy: .public) existing activity(ies)") for activity in existing { observe(activity) } + if #available(iOS 16.2, *) { endExtraActivities() } Task { [weak self] in for await activity in Activity.activityUpdates { - self?.logger.info("[LiveActivity] activityUpdates reported activity id=\(activity.id, privacy: .public) — push-to-start spawned an activity") + self?.logger.info("[LiveActivity] activityUpdates reported activity id=\(activity.id, privacy: .public) — push-to-start spawned the activity") self?.observe(activity) + if #available(iOS 16.2, *) { self?.endExtraActivities() } } } } @available(iOS 16.1, *) private func observe(_ activity: Activity) { - logger.info("[LiveActivity] observing activity id=\(activity.id, privacy: .public) session=\(activity.attributes.sessionID, privacy: .public)") + currentActivityID = activity.id + logger.info("[LiveActivity] observing aggregate activity id=\(activity.id, privacy: .public)") Task { [weak self] in for await tokenData in activity.pushTokenUpdates { let hex = tokenData.map { String(format: "%02x", $0) }.joined() self?.handleActivityToken(activity: activity, hex: hex) } } - // The desktop reuses an activity across re-runs and never ends it - // itself, so the only way it goes away is the user dismissing it. - // Report that so the desktop forgets the activity and the next run - // push-to-starts a fresh one instead of pushing to a dead token. + // The desktop reuses the activity and never ends it itself, so the + // only way it goes away is the user dismissing it. Report that so the + // desktop forgets the activity and the next job push-to-starts a fresh + // one instead of pushing to a dead token. Task { [weak self] in for await activityState in activity.activityStateUpdates { if activityState == .dismissed || activityState == .ended { @@ -146,86 +159,132 @@ final class MobileLiveActivityCoordinator { @available(iOS 16.1, *) private func handleActivityToken(activity: Activity, hex: String) { - let sessionID = activity.attributes.sessionID - guard activityTokens[activity.id]?.tokenHex != hex else { return } - activityTokens[activity.id] = (sessionID, hex) - logger.info("[LiveActivity] activity token id=\(activity.id, privacy: .public) session=\(sessionID, privacy: .public) \(hex.prefix(8), privacy: .public)…") + guard activityTokenHex != hex else { return } + activityTokenHex = hex + currentActivityID = activity.id + logger.info("[LiveActivity] aggregate activity token id=\(activity.id, privacy: .public) \(hex.prefix(8), privacy: .public)…") let activityID = activity.id Task { [weak state] in await state?.sendLiveActivityToken(LiveActivityTokenPayload( - activityTokenHex: hex, activityID: activityID, sessionID: sessionID + activityTokenHex: hex, activityID: activityID )) } } - /// The user dismissed (or the system ended) an activity. Drop our local - /// token and tell the desktop so it forgets the activity — the next run of - /// this session will then push-to-start a fresh one. + /// The user dismissed (or the system ended) the activity. Drop our local + /// token and tell the desktop so it forgets the activity — the next job + /// will then push-to-start a fresh one. @available(iOS 16.1, *) private func handleActivityDismissed( _ activity: Activity, activityState: ActivityState ) { - let sessionID = activity.attributes.sessionID let activityID = activity.id - activityTokens.removeValue(forKey: activityID) - locallyStartedSessions.remove(sessionID) - logger.info("[LiveActivity] activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) session=\(sessionID, privacy: .public) — reporting dismissal to desktop") + if intentionallyEndedActivityIDs.remove(activityID) != nil { + // We ended this activity ourselves to clear a duplicate; the + // surviving activity still stands, so this is not a user dismissal + // and must not be reported to the desktop. + logger.info("[LiveActivity] duplicate activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) — not reporting as dismissal") + return + } + if currentActivityID == activityID { + currentActivityID = nil + activityTokenHex = nil + locallyStarted = false + } + logger.info("[LiveActivity] aggregate activity \(String(describing: activityState), privacy: .public) id=\(activityID, privacy: .public) — reporting dismissal to desktop") Task { [weak state] in await state?.sendLiveActivityToken(LiveActivityTokenPayload( - activityID: activityID, sessionID: sessionID, activityDismissed: true + activityID: activityID, activityDismissed: true )) } } // MARK: - Foreground local start - /// Start a local Live Activity for every streaming job that doesn't have - /// one yet — but only while the app is in the foreground, where + /// Start the aggregate Live Activity if jobs are running and it does not + /// exist yet — but only while the app is in the foreground, where /// `Activity.request` works without spending the push-to-start budget. - private func startLocalActivitiesIfForeground(for sessions: [SessionSummary]) { + private func startActivityIfNeeded(for sessions: [SessionSummary]) { guard #available(iOS 16.2, *) else { return } guard UIApplication.shared.applicationState == .active else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - let live = Activity.activities - for session in sessions where session.isStreaming { - let sid = session.id - guard !locallyStartedSessions.contains(sid), - !live.contains(where: { $0.attributes.sessionID == sid }) - else { continue } - startActivityLocally(for: session) + // One aggregate activity total — nothing to do if it already exists. + guard Activity.activities.isEmpty, !locallyStarted else { + return } + let running = sessions.filter(\.isStreaming) + guard !running.isEmpty else { return } + startActivityLocally(running: running) } /// Create the activity in-process and observe it so its push token reaches /// the desktop, which then drives updates exactly as for a pushed activity. @available(iOS 16.2, *) - private func startActivityLocally(for session: SessionSummary) { - let sid = session.id - let projectName = state?.projects.first { $0.id == session.projectId }?.name ?? "" - let attributes = RxCodeJobActivityAttributes( - sessionID: sid, projectName: projectName, title: session.title - ) - let contentState = RxCodeJobActivityAttributes.ContentState( - phase: .running, - todoDone: session.progress?.done ?? 0, - todoTotal: session.progress?.total ?? 0, - currentStep: session.todos?.first { $0.status == .inProgress }?.activeForm, - updatedAt: Date().timeIntervalSince1970 - ) + private func startActivityLocally(running: [SessionSummary]) { + let contentState = makeContentState(running: running) do { let activity = try Activity.request( - attributes: attributes, + attributes: RxCodeJobActivityAttributes(), content: ActivityContent( state: contentState, staleDate: Date().addingTimeInterval(3600) ), pushType: .token ) - locallyStartedSessions.insert(sid) - logger.info("[LiveActivity] started locally session=\(sid, privacy: .public) id=\(activity.id, privacy: .public) — foreground, no push-to-start needed") + locallyStarted = true + currentActivityID = activity.id + logger.info("[LiveActivity] started aggregate activity locally id=\(activity.id, privacy: .public) jobs=\(running.count, privacy: .public) — foreground, no push-to-start needed") + // Tell the desktop right now — before the per-activity push token, + // which APNs can take several seconds to mint — so it cancels the + // deferred push-to-start and never spawns a duplicate activity. + let activityID = activity.id + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityID: activityID, activityStartedLocally: true + )) + } observe(activity) + endExtraActivities() } catch { - logger.error("[LiveActivity] local start failed session=\(sid, privacy: .public): \(error.localizedDescription, privacy: .public) — desktop push-to-start will cover it") + logger.error("[LiveActivity] local start failed: \(error.localizedDescription, privacy: .public) — desktop push-to-start will cover it") + } + } + + /// Build an aggregate content-state from the currently streaming sessions. + /// The desktop reconciles it with the full picture (including finished + /// jobs) the moment the per-activity token registers. + private func makeContentState(running: [SessionSummary]) -> RxCodeJobActivityAttributes.ContentState { + let jobs = running.map { session in + RxCodeJobActivityAttributes.ContentState.Job( + id: session.id, + phase: .running, + title: session.title, + projectName: state?.projects.first { $0.id == session.projectId }?.name ?? "", + todoDone: session.progress?.done ?? 0, + todoTotal: session.progress?.total ?? 0, + currentStep: session.todos?.first { $0.status == .inProgress }?.activeForm + ) + } + return RxCodeJobActivityAttributes.ContentState( + jobs: jobs, updatedAt: Date().timeIntervalSince1970 + ) + } + + /// Ensure at most one Live Activity exists. iOS can still spawn a second + /// one — the desktop's push-to-start travels over APNs, not the relay, so + /// it can race a foreground local start when the relay briefly drops — so + /// whenever the activity set changes, end every extra activity. + @available(iOS 16.2, *) + private func endExtraActivities() { + let activities = Activity.activities + guard activities.count > 1 else { return } + let keepID = activities.first { $0.id == currentActivityID }?.id ?? activities[0].id + currentActivityID = keepID + for extra in activities where extra.id != keepID { + let extraID = extra.id + logger.warning("[LiveActivity] ending duplicate activity id=\(extraID, privacy: .public) — keeping id=\(keepID, privacy: .public)") + intentionallyEndedActivityIDs.insert(extraID) + Task { await extra.end(nil, dismissalPolicy: .immediate) } } } @@ -237,10 +296,18 @@ final class MobileLiveActivityCoordinator { await state?.sendLiveActivityToken(LiveActivityTokenPayload(pushToStartTokenHex: startTokenHex)) } } - for (activityID, entry) in activityTokens { + if let activityTokenHex, let currentActivityID { + Task { [weak state] in + await state?.sendLiveActivityToken(LiveActivityTokenPayload( + activityTokenHex: activityTokenHex, activityID: currentActivityID + )) + } + } else if locallyStarted, let currentActivityID { + // Local start whose per-activity token has not been minted yet — + // re-assert it so a reconnect still suppresses the push-to-start. Task { [weak state] in await state?.sendLiveActivityToken(LiveActivityTokenPayload( - activityTokenHex: entry.tokenHex, activityID: activityID, sessionID: entry.sessionID + activityID: currentActivityID, activityStartedLocally: true )) } } diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 95967ec..b5176db 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -22,6 +22,7 @@ struct MobileChatView: View { @State private var showingTodoSheet = false @State private var showingRunProfiles = false @State private var showingBrowser = false + @State private var showingChanges = false /// The question request whose sheet is currently presented, if any. @State private var presentedQuestion: PendingQuestionPayload? /// The plan whose review sheet is currently presented, if any. @@ -94,6 +95,12 @@ struct MobileChatView: View { } } } + .sheet(isPresented: $showingChanges) { + ThreadChangesSheet(sessionID: sessionID) + .environmentObject(state) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .fullScreenCover(isPresented: $showingBrowser) { NavigationStack { MobileInAppBrowserView( @@ -194,6 +201,11 @@ struct MobileChatView: View { Label("Run Profiles", systemImage: "play.rectangle") } .disabled(currentProjectID == nil) + Button { + showingChanges = true + } label: { + Label("View Changes", systemImage: "plus.forwardslash.minus") + } Divider() Button { showingRenameSheet = true diff --git a/RxCodeMobile/Views/ThreadChangesSheet.swift b/RxCodeMobile/Views/ThreadChangesSheet.swift new file mode 100644 index 0000000..d163c46 --- /dev/null +++ b/RxCodeMobile/Views/ThreadChangesSheet.swift @@ -0,0 +1,346 @@ +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +/// Sheet listing the changes for a thread, reached from the thread's ⋯ menu. +/// A segmented control switches between every file edited across the thread +/// session ("This Turn") and the project's uncommitted git changes +/// ("Uncommitted"). Tapping a file pushes a full diff page. +struct ThreadChangesSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + let sessionID: String + + private enum Tab: Hashable { + case thisTurn + case uncommitted + } + + @State private var tab: Tab = .thisTurn + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("Changes", selection: $tab) { + Text("This Turn").tag(Tab.thisTurn) + Text("Uncommitted").tag(Tab.uncommitted) + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + content + } + .navigationTitle("Changes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(state.isLoadingThreadChanges) + .accessibilityLabel("Refresh") + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + await state.requestThreadChanges(sessionID: sessionID) + } + + /// The current result, but only when it belongs to this thread — a stale + /// result for a previously-opened thread is treated as "not yet loaded". + private var result: ThreadChangesResultPayload? { + guard let changes = state.threadChanges, changes.sessionID == sessionID else { return nil } + return changes + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if let result { + switch tab { + case .thisTurn: + // Thread edits are valid even when the git lookup failed, so + // they are never gated behind `ok`. + turnList(result.turnEdits) + case .uncommitted: + if result.ok { + uncommittedList(result.uncommitted) + } else { + errorState(result.errorMessage ?? "Could not load changes.") + } + } + } else if state.isPaired { + loadingState + } else { + errorState("Not connected to your Mac.") + } + } + + private var loadingState: some View { + VStack(spacing: 12) { + ProgressView() + Text("Loading changes…") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(_ message: String) -> some View { + ContentUnavailableView { + Label("Couldn't Load Changes", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Retry") { Task { await load() } } + } + } + + private func emptyState(_ message: String) -> some View { + ContentUnavailableView { + Label("No Changes", systemImage: "checkmark.circle") + } description: { + Text(message) + } + } + + // MARK: - This Turn + + @ViewBuilder + private func turnList(_ edits: [SyncFileEdit]) -> some View { + if edits.isEmpty { + emptyState("No files have been edited in this thread yet.") + } else { + List(edits) { edit in + NavigationLink { + ThreadChangeDetailView( + title: edit.name, + subtitle: edit.path, + diff: .hunks(edit.hunks.map { + PreviewFile.EditHunk(oldString: $0.oldString, newString: $0.newString) + }), + truncated: false + ) + } label: { + fileRow( + name: edit.name, + path: edit.path, + badge: edit.containsWrite ? "W" : "M", + badgeColor: edit.containsWrite ? .blue : .orange, + stat: hunkStat(edit.hunks) + ) + } + } + .listStyle(.plain) + .refreshable { await load() } + } + } + + // MARK: - Uncommitted + + @ViewBuilder + private func uncommittedList(_ changes: [SyncGitChange]) -> some View { + if changes.isEmpty { + emptyState("No uncommitted changes.") + } else { + List { + gitSection("Staged", changes.filter { $0.kind == .staged }) + gitSection("Unstaged", changes.filter { $0.kind == .unstaged }) + gitSection("Untracked", changes.filter { $0.kind == .untracked }) + } + .listStyle(.insetGrouped) + .refreshable { await load() } + } + } + + @ViewBuilder + private func gitSection(_ title: String, _ changes: [SyncGitChange]) -> some View { + if !changes.isEmpty { + Section(title) { + ForEach(changes) { change in + NavigationLink { + ThreadChangeDetailView( + title: fileName(change.displayPath), + subtitle: change.displayPath, + diff: .unified(change.unifiedDiff), + truncated: change.truncated + ) + } label: { + fileRow( + name: fileName(change.displayPath), + path: change.displayPath, + badge: change.statusChar, + badgeColor: gitStatusColor(change), + stat: unifiedStat(change.unifiedDiff) + ) + } + } + } + } + } + + // MARK: - Row + + private func fileRow( + name: String, + path: String, + badge: String, + badgeColor: Color, + stat: (added: Int, removed: Int) + ) -> some View { + HStack(spacing: 10) { + Text(badge) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(badgeColor, in: RoundedRectangle(cornerRadius: 5)) + + VStack(alignment: .leading, spacing: 2) { + Text(name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + Text(path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + + Spacer(minLength: 8) + + HStack(spacing: 6) { + if stat.added > 0 { + Text("+\(stat.added)").foregroundStyle(.green) + } + if stat.removed > 0 { + Text("−\(stat.removed)").foregroundStyle(.red) + } + } + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + } + .padding(.vertical, 2) + } + + // MARK: - Helpers + + private func fileName(_ path: String) -> String { + (path as NSString).lastPathComponent + } + + private func gitStatusColor(_ change: SyncGitChange) -> Color { + if change.kind == .untracked { return .blue } + switch change.statusChar { + case "A": return .green + case "D": return .red + case "R", "C": return .purple + default: return .orange + } + } + + private func hunkStat(_ hunks: [SyncEditHunk]) -> (added: Int, removed: Int) { + var added = 0 + var removed = 0 + for hunk in hunks { + if !hunk.oldString.isEmpty { + removed += hunk.oldString.components(separatedBy: "\n").count + } + if !hunk.newString.isEmpty { + added += hunk.newString.components(separatedBy: "\n").count + } + } + return (added, removed) + } + + private func unifiedStat(_ diff: String) -> (added: Int, removed: Int) { + var added = 0 + var removed = 0 + for line in diff.components(separatedBy: "\n") { + if line.hasPrefix("+"), !line.hasPrefix("+++") { + added += 1 + } else if line.hasPrefix("-"), !line.hasPrefix("---") { + removed += 1 + } + } + return (added, removed) + } +} + +/// Full-screen diff page for one changed file, pushed from `ThreadChangesSheet`. +struct ThreadChangeDetailView: View { + enum Diff { + case unified(String) + case hunks([PreviewFile.EditHunk]) + } + + let title: String + let subtitle: String + let diff: Diff + let truncated: Bool + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.horizontal, 8) + + if truncated { + Label( + "Diff truncated — open this file on your Mac for the full diff.", + systemImage: "scissors" + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + } + + diffBody + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private var diffBody: some View { + switch diff { + case .unified(let text): + if text.isEmpty { + emptyDiff + } else { + ChangeDiffView(unifiedDiff: text) + } + case .hunks(let hunks): + if hunks.isEmpty { + emptyDiff + } else { + ChangeDiffView(hunks: hunks) + } + } + } + + private var emptyDiff: some View { + Text("No diff content available.") + .font(.callout) + .foregroundStyle(.secondary) + .padding() + } +} diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift index 616320b..529a235 100644 --- a/RxCodeWidget/RxCodeJobActivity.swift +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -2,61 +2,90 @@ // RxCodeJobActivity.swift // RxCode // -// Shared Live Activity attributes for an RxCode "job" — one in-progress chat -// session (agent run). This file is compiled into BOTH the RxCodeMobile app -// target (which starts and observes the activity) and the RxCodeWidget -// extension (which renders it). +// Shared Live Activity attributes for RxCode's ongoing jobs. A *single* +// Live Activity per device aggregates every in-progress agent run — one +// activity, no matter how many jobs are running, keeps the app well within +// the scarce iOS push-to-start budget. +// +// This file is compiled into BOTH the RxCodeMobile app target (which starts +// and observes the activity) and the RxCodeWidget extension (which renders +// it). // // The desktop builds the APNs `content-state` JSON by hand, so the field // names and types here are a wire contract: keep them in sync with // `MobileSyncService` on macOS. Only plain JSON-friendly types are used -// (String / Int / Double / String-backed enum) so ActivityKit can decode a -// pushed content-state without a custom strategy. +// (String / Int / Double / String-backed enum / arrays of those) so +// ActivityKit can decode a pushed content-state without a custom strategy. // #if os(iOS) import ActivityKit import Foundation -/// Live Activity descriptor for a single ongoing RxCode job. +/// Live Activity descriptor for RxCode's ongoing jobs. The activity itself +/// carries no static attributes — it is device-scoped and every job lives in +/// the mutable `ContentState`. struct RxCodeJobActivityAttributes: ActivityAttributes { /// The mutable part, refreshed via APNs `update` pushes from the desktop. struct ContentState: Codable, Hashable { - /// Lifecycle phase of the job. - enum Phase: String, Codable, Hashable { - /// The agent is still working. - case running - /// The agent finished. The activity stays in this state until the - /// user dismisses it — the desktop never ends it automatically. - case done + /// One agent job (chat session) tracked by the activity. + struct Job: Codable, Hashable, Identifiable { + /// Lifecycle phase of a single job. + enum Phase: String, Codable, Hashable { + /// The agent is still working. + case running + /// The agent finished. The job stays in the list — in this + /// state — until the user dismisses the whole activity. + case done + } + + /// Chat-session id the job belongs to. Stable for its lifetime + /// and used as the deep-link target. + var id: String + /// Lifecycle phase. + var phase: Phase + /// Thread title. The desktop swaps in an AI-summarized title + /// shortly after the job starts, pushed as a content update. + var title: String + /// Project display name, shown as the subtitle. + var projectName: String + /// Completed todo count. `todoTotal == 0` means no todo list. + var todoDone: Int + /// Total todo count; zero when the job has no todo list yet. + var todoTotal: Int + /// Active-form label of the in-progress todo (e.g. "Running + /// tests"). `nil` when there is no todo list or nothing is active. + var currentStep: String? + + /// `true` when the job has a todo list to render as steps. + var hasTodos: Bool { todoTotal > 0 } + + /// Fractional progress 0...1; zero when the job has no todo list. + var fractionComplete: Double { + guard todoTotal > 0 else { return 0 } + return min(1, max(0, Double(todoDone) / Double(todoTotal))) + } } - var phase: Phase - /// Completed todo count. Zero/`todoTotal == 0` means no todo list. - var todoDone: Int - /// Total todo count; zero when the job has no todo list yet. - var todoTotal: Int - /// Active-form label of the in-progress todo (e.g. "Running tests"). - /// `nil` when there is no todo list or nothing is in progress. - var currentStep: String? + /// Every tracked job: those still running plus recently finished ones, + /// in the order they started. + var jobs: [Job] /// Desktop-side update time, unix seconds. A `Double` rather than /// `Date` so a pushed content-state decodes without a date strategy. var updatedAt: Double - /// Fractional progress 0...1; zero when the job has no todo list. - var fractionComplete: Double { - guard todoTotal > 0 else { return 0 } - return min(1, max(0, Double(todoDone) / Double(todoTotal))) + /// Jobs still being worked on. + var runningJobs: [Job] { jobs.filter { $0.phase == .running } } + /// Jobs that have finished. + var doneJobs: [Job] { jobs.filter { $0.phase == .done } } + /// Count of jobs still running — the headline number for the UI. + var runningCount: Int { runningJobs.count } + /// `true` once every tracked job has finished. + var allDone: Bool { !jobs.isEmpty && runningJobs.isEmpty } + /// The single job to feature when exactly one is running, else `nil`. + var soleRunningJob: Job? { + runningJobs.count == 1 ? runningJobs.first : nil } - - var hasTodos: Bool { todoTotal > 0 } } - - /// Chat-session id the job belongs to. Stable for the activity's lifetime. - var sessionID: String - /// Project display name, shown as the subtitle. - var projectName: String - /// Thread title. - var title: String } #endif diff --git a/RxCodeWidget/RxCodeWidgetLiveActivity.swift b/RxCodeWidget/RxCodeWidgetLiveActivity.swift index 75004c8..61bb4cc 100644 --- a/RxCodeWidget/RxCodeWidgetLiveActivity.swift +++ b/RxCodeWidget/RxCodeWidgetLiveActivity.swift @@ -2,9 +2,10 @@ // RxCodeWidgetLiveActivity.swift // RxCodeWidget // -// Live Activity for an RxCode ongoing job. Mirrors the desktop menubar: -// an "Ongoing job" title plus todo-list progress while the agent works, -// and a "Done" state when it finishes. +// Live Activity for RxCode's ongoing jobs. A single activity aggregates +// every in-progress agent job: it shows a step progress bar when exactly one +// job is running, and an in-progress count plus a compact list when several +// are. A green "all done" state stays on screen until the user dismisses it. // import ActivityKit @@ -13,186 +14,377 @@ import SwiftUI /// RxCode terracotta accent (#D97757). private let rxAccent = Color(red: 0xD9 / 255, green: 0x77 / 255, blue: 0x57 / 255) +/// Unfilled progress-track color. +private let rxTrack = Color.secondary.opacity(0.25) + +private typealias JobState = RxCodeJobActivityAttributes.ContentState +private typealias Job = RxCodeJobActivityAttributes.ContentState.Job struct RxCodeWidgetLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: RxCodeJobActivityAttributes.self) { context in // Lock screen / banner presentation. - JobLockScreenView(context: context) + JobsLockScreenView(state: context.state) .activityBackgroundTint(Color.black.opacity(0.55)) .activitySystemActionForegroundColor(rxAccent) } dynamicIsland: { context in - DynamicIsland { + let state = context.state + return DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) .font(.title3) } DynamicIslandExpandedRegion(.trailing) { - Text(trailingLabel(for: context.state)) + Text(statusBadge(state)) .font(.caption.weight(.semibold).monospacedDigit()) - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + .foregroundStyle(state.allDone ? Color.green : rxAccent) } DynamicIslandExpandedRegion(.center) { - Text(context.attributes.title) + Text(headlineTitle(state)) .font(.subheadline.weight(.semibold)) .lineLimit(1) } DynamicIslandExpandedRegion(.bottom) { - JobExpandedBottomView(context: context) + JobsExpandedBottomView(state: state) } } compactLeading: { - Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) } compactTrailing: { - Text(trailingLabel(for: context.state)) + Text(compactTrailing(state)) .font(.caption2.weight(.semibold).monospacedDigit()) - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + .foregroundStyle(state.allDone ? Color.green : rxAccent) } minimal: { - Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) } - .widgetURL(URL(string: "rxcode://thread/\(context.attributes.sessionID)")) + .widgetURL(deepLink(state)) .keylineTint(rxAccent) } } +} + +// MARK: - Presentation helpers + +private func leadingIcon(_ state: JobState) -> String { + state.allDone ? "checkmark.circle.fill" : "hammer.fill" +} + +/// Headline title: the sole running job's title, or an N-jobs summary. +private func headlineTitle(_ state: JobState) -> String { + if let job = state.soleRunningJob { return job.title } + if state.allDone { + return state.jobs.count == 1 ? "Job done" : "\(state.jobs.count) jobs done" + } + return "\(state.runningCount) jobs running" +} + +/// Trailing status badge for the lock screen / expanded island. +private func statusBadge(_ state: JobState) -> String { + if state.allDone { return "Done" } + if let job = state.soleRunningJob { + return job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "Working" + } + return "\(state.runningCount) running" +} - /// Compact label: a "✓" once done, "3/5" with a todo list, "•••" otherwise. - private func trailingLabel(for state: RxCodeJobActivityAttributes.ContentState) -> String { - if state.phase == .done { return "Done" } - if state.hasTodos { return "\(state.todoDone)/\(state.todoTotal)" } - return "•••" +/// Compact Dynamic Island trailing — tight on space, so digits only. +private func compactTrailing(_ state: JobState) -> String { + if state.allDone { return "✓" } + if let job = state.soleRunningJob { + return job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "•••" } + return "\(state.runningCount)" +} + +/// Deep-link to the single running job, or the app root for several. +private func deepLink(_ state: JobState) -> URL? { + if let job = state.soleRunningJob { + return URL(string: "rxcode://thread/\(job.id)") + } + return URL(string: "rxcode://") } // MARK: - Lock screen -private struct JobLockScreenView: View { - let context: ActivityViewContext +private struct JobsLockScreenView: View { + let state: JobState var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Image(systemName: context.state.phase == .done ? "checkmark.circle.fill" : "hammer.fill") - .foregroundStyle(context.state.phase == .done ? Color.green : rxAccent) - Text(context.state.phase == .done ? "Job done" : "Ongoing job") - .font(.caption.weight(.bold)) - .textCase(.uppercase) - .foregroundStyle(.secondary) - Spacer() - if context.state.phase == .running, context.state.hasTodos { - Text("\(context.state.todoDone)/\(context.state.todoTotal)") - .font(.caption.weight(.semibold).monospacedDigit()) - .foregroundStyle(rxAccent) - } + header + content + } + .padding(16) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: leadingIcon(state)) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + Text(headerLabel) + .font(.caption.weight(.bold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text(statusBadge(state)) + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(state.allDone ? Color.green : rxAccent) + } + } + + private var headerLabel: String { + if state.allDone { return "Jobs done" } + return state.runningCount == 1 ? "Ongoing job" : "Ongoing jobs" + } + + @ViewBuilder + private var content: some View { + if let job = state.soleRunningJob { + // Exactly one job: feature it with a full step progress bar. + SoleJobView(job: job) + } else if state.allDone { + VStack(alignment: .leading, spacing: 8) { + Text("\(state.jobs.count) \(state.jobs.count == 1 ? "job" : "jobs") completed") + .font(.subheadline.weight(.medium)) + StepProgressBar(done: 1, total: 0, isComplete: true) } + } else { + // Several jobs: lead with the count, list the rest. + MultiJobView(state: state) + } + } +} + +/// The featured single-job layout: title, project, and a step progress bar. +private struct SoleJobView: View { + let job: Job + var body: some View { + VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 2) { - Text(context.attributes.title) + Text(job.title) .font(.headline) .lineLimit(1) - if !context.attributes.projectName.isEmpty { - Text(context.attributes.projectName) + if !job.projectName.isEmpty { + Text(job.projectName) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } - - JobProgressView(state: context.state) - - if context.state.phase == .running, - let step = context.state.currentStep, !step.isEmpty { + StepProgressBar(done: job.todoDone, total: job.todoTotal) + if let step = job.currentStep, !step.isEmpty { Text(step) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } - .padding(16) } } -// MARK: - Dynamic Island expanded bottom - -private struct JobExpandedBottomView: View { - let context: ActivityViewContext +/// The multi-job layout: a compact row per running job, then an overflow line. +private struct MultiJobView: View { + let state: JobState + private let maxRows = 3 var body: some View { - VStack(alignment: .leading, spacing: 6) { - if !context.attributes.projectName.isEmpty { - Text(context.attributes.projectName) - .font(.caption2) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(state.runningJobs.prefix(maxRows))) { job in + JobRowView(job: job) + } + let overflow = state.runningCount - maxRows + if overflow > 0 { + Text("+\(overflow) more") + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) + } + } + } +} + +/// One compact row: a title line plus a thin step progress bar. +private struct JobRowView: View { + let job: Job + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle() + .fill(rxAccent) + .frame(width: 5, height: 5) + Text(job.title) + .font(.subheadline.weight(.medium)) .lineLimit(1) + Spacer(minLength: 6) + Text(job.hasTodos ? "\(job.todoDone)/\(job.todoTotal)" : "•••") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) } - JobProgressView(state: context.state) - if context.state.phase == .running, - let step = context.state.currentStep, !step.isEmpty { - Text(step) - .font(.caption2) + StepProgressBar(done: job.todoDone, total: job.todoTotal, height: 4) + } + } +} + +// MARK: - Dynamic Island expanded bottom + +private struct JobsExpandedBottomView: View { + let state: JobState + + var body: some View { + if let job = state.soleRunningJob { + VStack(alignment: .leading, spacing: 6) { + if !job.projectName.isEmpty { + Text(job.projectName) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + StepProgressBar(done: job.todoDone, total: job.todoTotal) + if let step = job.currentStep, !step.isEmpty { + Text(step) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } else if state.allDone { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("All jobs done") + .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + } + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(state.runningJobs.prefix(2))) { job in + JobRowView(job: job) + } + let overflow = state.runningCount - 2 + if overflow > 0 { + Text("+\(overflow) more") + .font(.caption2) + .foregroundStyle(.secondary) + } } } } } -// MARK: - Progress bar +// MARK: - Step progress bar -/// A capsule progress bar driven by the job's todo list. Falls back to an -/// indeterminate-looking full accent bar when the job has no todo list, and a -/// solid green bar once the job is done. -private struct JobProgressView: View { - let state: RxCodeJobActivityAttributes.ContentState +/// A segmented "step" progress bar — one capsule per todo, filled as steps +/// complete. Above `stepCap` steps the segments would be too thin, so it +/// falls back to a continuous bar; with no todo list it shows a small +/// "working" sliver; once complete every segment turns green. +private struct StepProgressBar: View { + /// Completed step count. + let done: Int + /// Total step count; 0 renders the indeterminate sliver. + let total: Int + /// Paints the whole bar in the done color. + var isComplete: Bool = false + /// Bar height — thinner for compact list rows. + var height: CGFloat = 6 + + /// Above this step count discrete segments get too thin to read. + private let stepCap = 12 var body: some View { GeometryReader { geo in + bar(in: geo.size.width) + } + .frame(height: height) + } + + @ViewBuilder + private func bar(in width: CGFloat) -> some View { + if total >= 1, total <= stepCap { + let spacing: CGFloat = total > 8 ? 2 : 3 + let each = (width - spacing * CGFloat(total - 1)) / CGFloat(total) + HStack(spacing: spacing) { + ForEach(0.. 0 else { return 0.16 } // no todo list: a "working" sliver + return min(1, max(0, Double(done) / Double(total))) } } // MARK: - Previews -extension RxCodeJobActivityAttributes { - fileprivate static var preview: RxCodeJobActivityAttributes { - RxCodeJobActivityAttributes( - sessionID: "demo", - projectName: "RxCode", - title: "Add live activity support" - ) +extension RxCodeJobActivityAttributes.ContentState { + fileprivate static func makeJob( + _ id: String, _ title: String, _ project: String, + phase: Job.Phase = .running, done: Int = 0, total: Int = 0, + step: String? = nil + ) -> Job { + Job(id: id, phase: phase, title: title, projectName: project, + todoDone: done, todoTotal: total, currentStep: step) } -} -extension RxCodeJobActivityAttributes.ContentState { - fileprivate static var running: RxCodeJobActivityAttributes.ContentState { - .init(phase: .running, todoDone: 2, todoTotal: 5, - currentStep: "Implementing widget UI", updatedAt: 0) + /// Exactly one running job — featured with a step progress bar. + fileprivate static var oneRunning: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", + done: 2, total: 5, step: "Implementing widget UI"), + ], updatedAt: 0) + } + + /// One running job with no todo list — shows the "working" sliver. + fileprivate static var oneRunningNoTodos: Self { + .init(jobs: [ + makeJob("a", "Investigate flaky sync test", "RxCodeMobile"), + ], updatedAt: 0) + } + + /// Several running jobs — shows the in-progress count and a list. + fileprivate static var manyRunning: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", done: 2, total: 5), + makeJob("b", "Fix mobile sync reconnect", "RxCodeMobile", done: 1, total: 3), + makeJob("c", "Refactor marketplace fetch", "RxCode"), + makeJob("d", "Update onboarding copy", "Homepage", done: 3, total: 4), + ], updatedAt: 0) } - fileprivate static var done: RxCodeJobActivityAttributes.ContentState { - .init(phase: .done, todoDone: 5, todoTotal: 5, currentStep: nil, updatedAt: 0) + /// Every job finished — the green terminal state. + fileprivate static var allDone: Self { + .init(jobs: [ + makeJob("a", "Add live activity support", "RxCode", + phase: .done, done: 5, total: 5), + makeJob("b", "Fix mobile sync reconnect", "RxCodeMobile", + phase: .done, done: 3, total: 3), + ], updatedAt: 0) } } -#Preview("Live Activity", as: .content, using: RxCodeJobActivityAttributes.preview) { +#Preview("Live Activity", as: .content, using: RxCodeJobActivityAttributes()) { RxCodeWidgetLiveActivity() } contentStates: { - RxCodeJobActivityAttributes.ContentState.running - RxCodeJobActivityAttributes.ContentState.done + RxCodeJobActivityAttributes.ContentState.oneRunning + RxCodeJobActivityAttributes.ContentState.oneRunningNoTodos + RxCodeJobActivityAttributes.ContentState.manyRunning + RxCodeJobActivityAttributes.ContentState.allDone } From 1882b086ce431c65125ae787e13ccf024c464631 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 01:50:12 +0800 Subject: [PATCH 6/7] feat: add skill repo ui --- .../RxCodeCore/Models/GitHubModels.swift | 14 + .../RxCodeCore/Models/MarketplacePlugin.swift | 42 +- .../Sources/RxCodeSync/Protocol/Payload.swift | 470 +++++++++++++++++ RxCode/App/AppState.swift | 489 ++++++++++++++++++ RxCode/Services/GitHubService.swift | 26 + RxCode/Services/MCPService.swift | 8 + RxCode/Services/MarketplaceService.swift | 129 ++++- RxCode/Services/MobileSyncService.swift | 54 ++ RxCode/Services/NotificationService.swift | 30 ++ RxCode/Services/PersistenceService.swift | 12 + RxCode/Views/Chat/GitHubSheet.swift | 224 +++++++- RxCode/Views/Chat/SkillMarketView.swift | 206 ++++++++ RxCodeMobile/State/MobileAppState.swift | 376 ++++++++++++++ RxCodeMobile/Views/MobileACPClientsView.swift | 180 +++++++ RxCodeMobile/Views/MobileMCPServersView.swift | 308 +++++++++++ RxCodeMobile/Views/MobileSettingsView.swift | 31 ++ .../Views/MobileSkillMarketView.swift | 131 +++++ RxCodeWidget/RxCodeJobActivity.swift | 25 +- RxCodeWidget/RxCodeWidgetLiveActivity.swift | 4 +- 19 files changed, 2738 insertions(+), 21 deletions(-) create mode 100644 RxCodeMobile/Views/MobileACPClientsView.swift create mode 100644 RxCodeMobile/Views/MobileMCPServersView.swift create mode 100644 RxCodeMobile/Views/MobileSkillMarketView.swift diff --git a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift b/Packages/Sources/RxCodeCore/Models/GitHubModels.swift index 32503a1..965cb17 100644 --- a/Packages/Sources/RxCodeCore/Models/GitHubModels.swift +++ b/Packages/Sources/RxCodeCore/Models/GitHubModels.swift @@ -83,6 +83,20 @@ public struct DeviceCodeResponse: Codable, Sendable { } } +// MARK: - Custom Git Repository + +public struct CustomRepo: Identifiable, Codable, Sendable, Hashable { + public let id: UUID + public var name: String + public var cloneURL: String + + public init(id: UUID = UUID(), name: String, cloneURL: String) { + self.id = id + self.name = name + self.cloneURL = cloneURL + } +} + // MARK: - Device Flow: Access Token Response public struct AccessTokenResponse: Codable, Sendable { diff --git a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift index fb4478b..c9574ef 100644 --- a/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift +++ b/Packages/Sources/RxCodeCore/Models/MarketplacePlugin.swift @@ -83,6 +83,27 @@ public struct MarketplaceSource: Codable, Sendable, Hashable { } } +public struct MarketplaceCustomSource: Codable, Sendable, Hashable, Identifiable { + public var id: String { source.codexSource } + public var source: MarketplaceSource + public var defaultCategory: String + public var addedAt: Date + + public init( + source: MarketplaceSource, + defaultCategory: String = "custom", + addedAt: Date = Date() + ) { + self.source = source + self.defaultCategory = defaultCategory + self.addedAt = addedAt + } + + public var displayName: String { + source.codexSource + } +} + public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable { public var id: String { "\(marketplace)/\(name)" } public var name: String @@ -131,10 +152,29 @@ public struct MarketplacePluginRecord: Codable, Sendable, Hashable, Identifiable public struct MarketplacePluginConfiguration: Codable, Sendable, Equatable { public var version: Int public var plugins: [MarketplacePluginRecord] + public var customSources: [MarketplaceCustomSource] - public init(version: Int = 1, plugins: [MarketplacePluginRecord] = []) { + public init( + version: Int = 2, + plugins: [MarketplacePluginRecord] = [], + customSources: [MarketplaceCustomSource] = [] + ) { self.version = version self.plugins = plugins + self.customSources = customSources + } + + private enum CodingKeys: String, CodingKey { + case version + case plugins + case customSources + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + plugins = try container.decodeIfPresent([MarketplacePluginRecord].self, forKey: .plugins) ?? [] + customSources = try container.decodeIfPresent([MarketplaceCustomSource].self, forKey: .customSources) ?? [] } } diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index d123603..09ead8c 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -45,6 +45,18 @@ public enum Payload: Sendable { case runProfileRunRequest(RunProfileRunRequestPayload) case runProfileStopRequest(RunProfileStopRequestPayload) case runTaskUpdate(RunTaskUpdatePayload) + case skillCatalogRequest(SkillCatalogRequestPayload) + case skillCatalogResult(SkillCatalogResultPayload) + case skillMutationRequest(SkillMutationRequestPayload) + case skillMutationResult(SkillMutationResultPayload) + case acpRegistryRequest(ACPRegistryRequestPayload) + case acpRegistryResult(ACPRegistryResultPayload) + case acpMutationRequest(ACPMutationRequestPayload) + case acpMutationResult(ACPMutationResultPayload) + case mcpConfigRequest(MCPConfigRequestPayload) + case mcpConfigResult(MCPConfigResultPayload) + case mcpMutationRequest(MCPMutationRequestPayload) + case mcpMutationResult(MCPMutationResultPayload) case ping(PingPayload) case pong(PongPayload) case unknown(type: String) @@ -91,6 +103,18 @@ public extension Payload { case .runProfileRunRequest: return "run_profile_run_request" case .runProfileStopRequest: return "run_profile_stop_request" case .runTaskUpdate: return "run_task_update" + case .skillCatalogRequest: return "skill_catalog_request" + case .skillCatalogResult: return "skill_catalog_result" + case .skillMutationRequest: return "skill_mutation_request" + case .skillMutationResult: return "skill_mutation_result" + case .acpRegistryRequest: return "acp_registry_request" + case .acpRegistryResult: return "acp_registry_result" + case .acpMutationRequest: return "acp_mutation_request" + case .acpMutationResult: return "acp_mutation_result" + case .mcpConfigRequest: return "mcp_config_request" + case .mcpConfigResult: return "mcp_config_result" + case .mcpMutationRequest: return "mcp_mutation_request" + case .mcpMutationResult: return "mcp_mutation_result" case .ping: return "ping" case .pong: return "pong" case .unknown(let type): return type @@ -687,6 +711,416 @@ public struct RunTaskUpdatePayload: Codable, Sendable { } } +// MARK: - Skills / ACP / MCP remote management + +/// Mobile asks the desktop for the skill marketplace catalog. `forceRefresh` +/// bypasses the desktop's 5-minute marketplace cache. +public struct SkillCatalogRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// One marketplace plugin flattened from the desktop's `MarketplacePlugin` +/// plus its current install state. `id` mirrors `MarketplacePlugin.id`. +public struct MobileSkillPlugin: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let summary: String + public let author: String + public let category: String + public let categoryLabel: String + public let marketplace: String + public let marketplaceLabel: String + public let homepage: String + public let isInstalled: Bool + + public init( + id: String, + name: String, + summary: String, + author: String, + category: String, + categoryLabel: String, + marketplace: String, + marketplaceLabel: String, + homepage: String, + isInstalled: Bool + ) { + self.id = id + self.name = name + self.summary = summary + self.author = author + self.category = category + self.categoryLabel = categoryLabel + self.marketplace = marketplace + self.marketplaceLabel = marketplaceLabel + self.homepage = homepage + self.isInstalled = isInstalled + } +} + +public struct SkillCatalogResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + } +} + +/// Mobile asks the desktop to install or remove a marketplace skill. `pluginID` +/// is the catalog id; the desktop re-resolves the authoritative plugin from its +/// own freshly-fetched catalog. +public struct SkillMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + } + + public let clientRequestID: UUID + public let operation: Operation + public let pluginID: String + + public init(clientRequestID: UUID = UUID(), operation: Operation, pluginID: String) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + } +} + +public struct SkillMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillMutationRequestPayload.Operation + public let pluginID: String + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + + public init( + clientRequestID: UUID, + operation: SkillMutationRequestPayload.Operation, + pluginID: String, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + } +} + +/// Mobile asks the desktop for the ACP agent registry plus installed clients. +public struct ACPRegistryRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// A registry agent flattened from the desktop's `ACPRegistryAgent`, plus +/// whether a matching client is already installed locally. +public struct MobileACPRegistryAgent: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let version: String + public let summary: String + public let authors: [String] + public let license: String? + public let website: String? + public let iconURL: String? + public let isInstalled: Bool + public let hasBinary: Bool + public let hasNpx: Bool + public let hasUvx: Bool + + public init( + id: String, + name: String, + version: String, + summary: String, + authors: [String] = [], + license: String? = nil, + website: String? = nil, + iconURL: String? = nil, + isInstalled: Bool, + hasBinary: Bool, + hasNpx: Bool, + hasUvx: Bool + ) { + self.id = id + self.name = name + self.version = version + self.summary = summary + self.authors = authors + self.license = license + self.website = website + self.iconURL = iconURL + self.isInstalled = isInstalled + self.hasBinary = hasBinary + self.hasNpx = hasNpx + self.hasUvx = hasUvx + } +} + +/// An installed ACP client mirrored from the desktop's `ACPClientSpec`. +public struct MobileACPClient: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let registryId: String? + public let displayName: String + public let enabled: Bool + public let launchKind: String + public let modelCount: Int + public let iconURL: String? + + public init( + id: String, + registryId: String? = nil, + displayName: String, + enabled: Bool, + launchKind: String, + modelCount: Int, + iconURL: String? = nil + ) { + self.id = id + self.registryId = registryId + self.displayName = displayName + self.enabled = enabled + self.launchKind = launchKind + self.modelCount = modelCount + self.iconURL = iconURL + } +} + +public struct ACPRegistryResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop to install an ACP agent from the registry, remove an +/// installed client, or toggle a client's enabled flag. +public struct ACPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let registryAgentID: String? + public let clientID: String? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.registryAgentID = registryAgentID + self.clientID = clientID + self.enabled = enabled + } +} + +public struct ACPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: ACPMutationRequestPayload.Operation + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + operation: ACPMutationRequestPayload.Operation, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop for the configured global MCP servers. +public struct MCPConfigRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + + public init(clientRequestID: UUID = UUID()) { + self.clientRequestID = clientRequestID + } +} + +/// A plain key/value pair for MCP environment variables and headers. The +/// desktop's `MCPKeyValue` carries a non-Codable UUID, so the wire uses this. +public struct MobileMCPKeyValue: Codable, Sendable, Equatable, Hashable { + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +/// One global MCP server flattened from the desktop's `MCPServerRecord`. +public struct MobileMCPServer: Codable, Sendable, Identifiable, Equatable { + public var id: String { name } + + public let name: String + public let transport: String + public let url: String? + public let command: String? + public let args: [String] + public let env: [MobileMCPKeyValue] + public let headers: [MobileMCPKeyValue] + public let isGloballyEnabled: Bool + public let endpoint: String + + public init( + name: String, + transport: String, + url: String? = nil, + command: String? = nil, + args: [String] = [], + env: [MobileMCPKeyValue] = [], + headers: [MobileMCPKeyValue] = [], + isGloballyEnabled: Bool, + endpoint: String + ) { + self.name = name + self.transport = transport + self.url = url + self.command = command + self.args = args + self.env = env + self.headers = headers + self.isGloballyEnabled = isGloballyEnabled + self.endpoint = endpoint + } +} + +public struct MCPConfigResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + +/// Mobile asks the desktop to add/upsert, remove, or toggle a global MCP server. +public struct MCPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let serverName: String + public let server: MobileMCPServer? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.server = server + self.enabled = enabled + } +} + +public struct MCPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: MCPMutationRequestPayload.Operation + public let serverName: String + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + operation: MCPMutationRequestPayload.Operation, + serverName: String, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { public var id: String { "\(projectId.uuidString)::\(branch)" } @@ -1588,6 +2022,18 @@ extension Payload: Codable { case runProfileRunRequest = "run_profile_run_request" case runProfileStopRequest = "run_profile_stop_request" case runTaskUpdate = "run_task_update" + case skillCatalogRequest = "skill_catalog_request" + case skillCatalogResult = "skill_catalog_result" + case skillMutationRequest = "skill_mutation_request" + case skillMutationResult = "skill_mutation_result" + case acpRegistryRequest = "acp_registry_request" + case acpRegistryResult = "acp_registry_result" + case acpMutationRequest = "acp_mutation_request" + case acpMutationResult = "acp_mutation_result" + case mcpConfigRequest = "mcp_config_request" + case mcpConfigResult = "mcp_config_result" + case mcpMutationRequest = "mcp_mutation_request" + case mcpMutationResult = "mcp_mutation_result" case ping case pong } @@ -1638,6 +2084,18 @@ extension Payload: Codable { case .runProfileRunRequest: self = .runProfileRunRequest(try container.decode(RunProfileRunRequestPayload.self, forKey: .data)) case .runProfileStopRequest: self = .runProfileStopRequest(try container.decode(RunProfileStopRequestPayload.self, forKey: .data)) case .runTaskUpdate: self = .runTaskUpdate(try container.decode(RunTaskUpdatePayload.self, forKey: .data)) + case .skillCatalogRequest: self = .skillCatalogRequest(try container.decode(SkillCatalogRequestPayload.self, forKey: .data)) + case .skillCatalogResult: self = .skillCatalogResult(try container.decode(SkillCatalogResultPayload.self, forKey: .data)) + case .skillMutationRequest: self = .skillMutationRequest(try container.decode(SkillMutationRequestPayload.self, forKey: .data)) + case .skillMutationResult: self = .skillMutationResult(try container.decode(SkillMutationResultPayload.self, forKey: .data)) + case .acpRegistryRequest: self = .acpRegistryRequest(try container.decode(ACPRegistryRequestPayload.self, forKey: .data)) + case .acpRegistryResult: self = .acpRegistryResult(try container.decode(ACPRegistryResultPayload.self, forKey: .data)) + case .acpMutationRequest: self = .acpMutationRequest(try container.decode(ACPMutationRequestPayload.self, forKey: .data)) + case .acpMutationResult: self = .acpMutationResult(try container.decode(ACPMutationResultPayload.self, forKey: .data)) + case .mcpConfigRequest: self = .mcpConfigRequest(try container.decode(MCPConfigRequestPayload.self, forKey: .data)) + case .mcpConfigResult: self = .mcpConfigResult(try container.decode(MCPConfigResultPayload.self, forKey: .data)) + case .mcpMutationRequest: self = .mcpMutationRequest(try container.decode(MCPMutationRequestPayload.self, forKey: .data)) + case .mcpMutationResult: self = .mcpMutationResult(try container.decode(MCPMutationResultPayload.self, forKey: .data)) case .ping: self = .ping(try container.decode(PingPayload.self, forKey: .data)) case .pong: self = .pong(try container.decode(PongPayload.self, forKey: .data)) } @@ -1684,6 +2142,18 @@ extension Payload: Codable { case .runProfileRunRequest(let p): try container.encode(TypeKey.runProfileRunRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runProfileStopRequest(let p): try container.encode(TypeKey.runProfileStopRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .runTaskUpdate(let p): try container.encode(TypeKey.runTaskUpdate.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillCatalogRequest(let p): try container.encode(TypeKey.skillCatalogRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillCatalogResult(let p): try container.encode(TypeKey.skillCatalogResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillMutationRequest(let p): try container.encode(TypeKey.skillMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillMutationResult(let p): try container.encode(TypeKey.skillMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpRegistryRequest(let p): try container.encode(TypeKey.acpRegistryRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpRegistryResult(let p): try container.encode(TypeKey.acpRegistryResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpMutationRequest(let p): try container.encode(TypeKey.acpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .acpMutationResult(let p): try container.encode(TypeKey.acpMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpConfigRequest(let p): try container.encode(TypeKey.mcpConfigRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpConfigResult(let p): try container.encode(TypeKey.mcpConfigResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpMutationRequest(let p): try container.encode(TypeKey.mcpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .mcpMutationResult(let p): try container.encode(TypeKey.mcpMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .ping(let p): try container.encode(TypeKey.ping.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .pong(let p): try container.encode(TypeKey.pong.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .unknown(let type): try container.encode(type, forKey: .type) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 2e51531..a81effe 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -896,6 +896,8 @@ final class AppState { var isLoggedIn = false var gitHubUser: GitHubUser? var repos: [GitHubRepo] = [] + var customRepos: [CustomRepo] = [] + var isCloningCustomRepo: String? // MARK: - CLI Version @@ -910,6 +912,8 @@ final class AppState { var marketplaceLoading = false var marketplaceInstalledNames: Set = [] var marketplacePluginStates: [String: PluginInstallStatus] = [:] + var marketplaceCustomSources: [MarketplaceCustomSource] = [] + var marketplaceSourceError: String? // MARK: - Onboarding @@ -1329,6 +1333,90 @@ final class AppState { } mobileSyncObservers.append(planDecisionObserver) + let skillCatalogObserver = center.addObserver( + forName: .mobileSyncSkillCatalogRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillCatalogRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillCatalogRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillCatalogObserver) + + let skillMutationObserver = center.addObserver( + forName: .mobileSyncSkillMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillMutationObserver) + + let acpRegistryObserver = center.addObserver( + forName: .mobileSyncACPRegistryRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPRegistryRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPRegistryRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpRegistryObserver) + + let acpMutationObserver = center.addObserver( + forName: .mobileSyncACPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpMutationObserver) + + let mcpConfigObserver = center.addObserver( + forName: .mobileSyncMCPConfigRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPConfigRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPConfigRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpConfigObserver) + + let mcpMutationObserver = center.addObserver( + forName: .mobileSyncMCPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpMutationObserver) + observeMobileSnapshotInputs() } @@ -1776,6 +1864,328 @@ final class AppState { if ok { scheduleMobileSnapshotBroadcast() } } + // MARK: - Mobile: Skills / ACP / MCP remote management + + /// Error for malformed remote skill/ACP/MCP requests; its description is + /// surfaced verbatim to the mobile client. + private enum MobileRemoteConfigError: LocalizedError { + case invalidRequest(String) + + var errorDescription: String? { + switch self { + case .invalidRequest(let detail): return detail + } + } + } + + /// The marketplace catalog flattened into wire DTOs with current install + /// state. `forceRefresh` bypasses the 5-minute marketplace cache. + private func mobileSkillPlugins(forceRefresh: Bool = false) async -> [MobileSkillPlugin] { + let catalog = await marketplace.fetchCatalog(forceRefresh: forceRefresh) + let installed = await marketplace.installedPluginNames() + return catalog.map { plugin in + MobileSkillPlugin( + id: plugin.id, + name: plugin.name, + summary: plugin.description, + author: plugin.author, + category: plugin.category, + categoryLabel: plugin.categoryLabel, + marketplace: plugin.marketplace, + marketplaceLabel: plugin.marketplaceLabel, + homepage: plugin.homepage, + isInstalled: installed.contains(plugin.name) + ) + } + } + + private func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { + let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) + let result = SkillCatalogResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + plugins: plugins + ) + await MobileSyncService.shared.send(.skillCatalogResult(result), toHex: fromHex) + } + + private func handleMobileSkillMutationRequest(_ request: SkillMutationRequestPayload, fromHex: String) async { + let catalog = await marketplace.fetchCatalog() + guard let plugin = catalog.first(where: { $0.id == request.pluginID }) else { + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: false, + errorMessage: "Skill not found in the marketplace catalog.", + plugins: await mobileSkillPlugins() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + return + } + + var ok = true + var errorMessage: String? + do { + switch request.operation { + case .install: + try await marketplace.installPlugin(plugin) + marketplaceInstalledNames.insert(plugin.name) + marketplacePluginStates[plugin.id] = .installed + case .uninstall: + try await marketplace.uninstallPlugin(plugin) + marketplaceInstalledNames.remove(plugin.name) + marketplacePluginStates[plugin.id] = .notInstalled + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill mutation failed plugin=\(plugin.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok { + let verb = request.operation == .install ? "installed" : "removed" + await NotificationService.shared.postRemoteConfigChanged( + title: "Skill \(verb) remotely", + body: plugin.name + ) + } + + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: ok, + errorMessage: errorMessage, + plugins: await mobileSkillPlugins() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + } + + private func mobileACPRegistryAgents() -> [MobileACPRegistryAgent] { + let installedRegistryIDs = Set(acpClients.compactMap(\.registryId)) + return (acpRegistry?.agents ?? []).map { agent in + MobileACPRegistryAgent( + id: agent.id, + name: agent.name, + version: agent.version, + summary: agent.description, + authors: agent.authors ?? [], + license: agent.license, + website: agent.website, + iconURL: agent.icon, + isInstalled: installedRegistryIDs.contains(agent.id), + hasBinary: agent.distribution.binary?[ACPPlatform.current] != nil, + hasNpx: agent.distribution.npx != nil, + hasUvx: agent.distribution.uvx != nil + ) + } + } + + private func mobileACPClients() -> [MobileACPClient] { + acpClients.map { spec in + MobileACPClient( + id: spec.id, + registryId: spec.registryId, + displayName: spec.displayName, + enabled: spec.enabled, + launchKind: spec.launch.displayKind, + modelCount: spec.models.count, + iconURL: spec.iconURL + ) + } + } + + private func handleMobileACPRegistryRequest(_ request: ACPRegistryRequestPayload, fromHex: String) async { + await refreshACPRegistry(forceRefresh: request.forceRefresh) + let ok = acpRegistry != nil + let result = ACPRegistryResultPayload( + clientRequestID: request.clientRequestID, + ok: ok, + errorMessage: ok ? nil : "Could not load the ACP agent registry.", + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpRegistryResult(result), toHex: fromHex) + } + + private func handleMobileACPMutationRequest(_ request: ACPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + var bannerBody: String? + do { + switch request.operation { + case .install: + guard let agentID = request.registryAgentID else { + throw MobileRemoteConfigError.invalidRequest("Missing registry agent id.") + } + if acpRegistry == nil { await refreshACPRegistry() } + guard let agent = acpRegistry?.agents.first(where: { $0.id == agentID }) else { + throw MobileRemoteConfigError.invalidRequest("Agent not found in the registry.") + } + let spec = try await installACPClient(from: agent) + addACPClient(spec) + bannerTitle = "ACP agent installed remotely" + bannerBody = agent.name + case .uninstall: + guard let clientID = request.clientID, + let client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + removeACPClient(id: clientID) + bannerTitle = "ACP agent removed remotely" + bannerBody = client.displayName + case .setEnabled: + guard let clientID = request.clientID, + let enabled = request.enabled, + var client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + client.enabled = enabled + updateACPClient(client) + bannerTitle = "ACP agent \(enabled ? "enabled" : "disabled") remotely" + bannerBody = client.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] acp mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = ACPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + ok: ok, + errorMessage: errorMessage, + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpMutationResult(result), toHex: fromHex) + } + + private func mobileMCPServer(_ record: MCPServerRecord) -> MobileMCPServer { + let env = record.env + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let headers = record.headers + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let endpoint: String + if record.transport == .stdio { + endpoint = ([record.command ?? ""] + record.args) + .filter { !$0.isEmpty } + .joined(separator: " ") + } else { + endpoint = record.url ?? "" + } + return MobileMCPServer( + name: record.name, + transport: record.transport.rawValue, + url: record.url, + command: record.command, + args: record.args, + env: env, + headers: headers, + isGloballyEnabled: record.isGloballyEnabled, + endpoint: endpoint + ) + } + + private func mobileMCPServers() async throws -> [MobileMCPServer] { + try await mcp.globalRecords().map { mobileMCPServer($0) } + } + + private func mcpServerSpec(from server: MobileMCPServer) -> MCPServerSpec { + MCPServerSpec( + name: server.name, + transport: MCPTransport(rawValue: server.transport) ?? .stdio, + url: server.url ?? "", + headers: server.headers.map { MCPKeyValue(key: $0.key, value: $0.value) }, + command: server.command ?? "", + args: server.args, + env: server.env.map { MCPKeyValue(key: $0.key, value: $0.value) } + ) + } + + private func handleMobileMCPConfigRequest(_ request: MCPConfigRequestPayload, fromHex: String) async { + do { + let servers = try await mobileMCPServers() + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + servers: servers + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } catch { + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: false, + errorMessage: error.localizedDescription, + servers: [] + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } + } + + private func handleMobileMCPMutationRequest(_ request: MCPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + do { + switch request.operation { + case .add: + guard let server = request.server else { + throw MobileRemoteConfigError.invalidRequest("Missing server definition.") + } + try await mcp.add(spec: mcpServerSpec(from: server), scope: .user, projectPath: nil) + bannerTitle = "MCP server saved remotely" + case .remove: + try await mcp.remove(name: request.serverName, scope: .user) + bannerTitle = "MCP server removed remotely" + case .setEnabled: + guard let enabled = request.enabled else { + throw MobileRemoteConfigError.invalidRequest("Missing enabled flag.") + } + try await mcp.setGlobalEnabled(name: request.serverName, enabled: enabled) + bannerTitle = "MCP server \(enabled ? "enabled" : "disabled") remotely" + } + await refreshMCPServers() + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] mcp mutation failed server=\(request.serverName, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: request.serverName) + } + + var servers: [MobileMCPServer] = [] + do { + servers = try await mobileMCPServers() + } catch { + logger.error("[MobileSync] failed reading mcp servers for reply: \(error.localizedDescription, privacy: .public)") + } + let result = MCPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + serverName: request.serverName, + ok: ok, + errorMessage: errorMessage, + servers: servers + ) + await MobileSyncService.shared.send(.mcpMutationResult(result), toHex: fromHex) + } + private func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { let depth = max(0, min(request.depth, 2)) guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -3371,6 +3781,9 @@ final class AppState { _ = await github.loadToken() } + customRepos = await persistence.loadCustomRepos() + marketplaceCustomSources = await marketplace.customSources() + // Sidebar threads are now sourced from the local SwiftData store. // CLI session files are no longer surfaced in the sidebar list — the // CLI is still the transcript backend (replay on thread open), but @@ -5940,6 +6353,50 @@ final class AppState { await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) } + func loadCustomRepos() async { + customRepos = await persistence.loadCustomRepos() + } + + func addCustomRepo(url: String, name: String, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: url, to: clonePath) + let repo = CustomRepo(name: name, cloneURL: url) + customRepos.append(repo) + try await persistence.saveCustomRepos(customRepos) + await addAndSelectProject(name: name, path: clonePath, gitHubRepo: nil, in: window) + } + + func cloneCustomRepo(_ repo: CustomRepo, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(repo.name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: repo.cloneURL, to: clonePath) + await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: nil, in: window) + } + + func removeCustomRepo(_ repo: CustomRepo) async { + customRepos.removeAll { $0.id == repo.id } + do { + try await persistence.saveCustomRepos(customRepos) + } catch { + logger.error("Failed to save custom repos: \(error.localizedDescription)") + } + } + // MARK: - View Convenience API func startNewChat(in window: WindowState) { @@ -6953,6 +7410,7 @@ final class AppState { func loadMarketplace(forceRefresh: Bool = false) async { marketplaceLoading = true + marketplaceSourceError = nil defer { marketplaceLoading = false } async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) @@ -6964,6 +7422,7 @@ final class AppState { marketplaceCatalog = fetchedCatalog marketplaceInstalledNames = installedNames + marketplaceCustomSources = await marketplace.customSources() } func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { @@ -6988,6 +7447,36 @@ final class AppState { } } + @discardableResult + func addMarketplaceGitSource(url: String, ref: String?) async -> Bool { + marketplaceSourceError = nil + do { + _ = try await marketplace.addCustomGitSource(url: url, ref: ref) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to add marketplace Git source: \(error.localizedDescription)") + return false + } + } + + @discardableResult + func removeMarketplaceGitSource(_ source: MarketplaceCustomSource) async -> Bool { + marketplaceSourceError = nil + do { + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to remove marketplace Git source: \(error.localizedDescription)") + return false + } + } + // MARK: - Attachment Management func addAttachment(_ attachment: Attachment, in window: WindowState) { diff --git a/RxCode/Services/GitHubService.swift b/RxCode/Services/GitHubService.swift index db1d574..8dc83af 100644 --- a/RxCode/Services/GitHubService.swift +++ b/RxCode/Services/GitHubService.swift @@ -282,6 +282,32 @@ actor GitHubService { logger.info("Cloned \(repo.fullName, privacy: .public) to \(path, privacy: .public)") } + func cloneRepo(from url: String, to path: String) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["clone", url, path] + process.environment = ProcessInfo.processInfo.environment + + let stderrPipe = Pipe() + process.standardError = stderrPipe + + try process.run() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } + } + + guard process.terminationStatus == 0 else { + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "unknown error" + throw GitHubError.cloneFailed(stderr) + } + + logger.info("Cloned repo from \(url, privacy: .public) to \(path, privacy: .public)") + } + // MARK: - Private Helpers private func apiRequest( diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index 9197101..cfe98f4 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -43,6 +43,14 @@ actor MCPService { .map { makeInfo(record: $0, projectPath: projectPath) } } + /// Full server records sorted by name. Unlike `list`, this exposes the + /// command/args/env/headers needed to render an editable form (e.g. on the + /// mobile MCP screen). + func globalRecords() async throws -> [MCPServerRecord] { + try loadConfig().servers + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + func get(name: String, projectPath: String?) async throws -> MCPServerDetail { guard let record = try loadConfig().servers.first(where: { $0.name == name }) else { throw MCPError.parseFailure("MCP server '\(name)' not found") diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index 38b8424..c34f9f1 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -7,6 +7,19 @@ import os actor MarketplaceService { private let logger = Logger(subsystem: "com.claudework", category: "MarketplaceService") + enum CustomSourceError: LocalizedError { + case invalidGitHubURL + case duplicateSource + + var errorDescription: String? { + switch self { + case .invalidGitHubURL: + return "Enter a GitHub repository URL such as https://github.com/owner/repo." + case .duplicateSource: + return "This Git source is already included." + } + } + } /// Cached catalog with TTL. private var cachedCatalog: [MarketplacePlugin] = [] @@ -40,11 +53,17 @@ actor MarketplaceService { group.addTask { await self.fetchOpenAISkills() } - for source in Self.sourceRepos { + let customSources = loadCustomSources() + let repoSources = Self.sourceRepos.map { + (source: MarketplaceSource(owner: $0.owner, repo: $0.repo), defaultCategory: $0.defaultCategory) + } + customSources.map { + (source: $0.source, defaultCategory: $0.defaultCategory) + } + + for source in repoSources { group.addTask { await self.fetchRepoPlugins( - owner: source.owner, - repo: source.repo, + source: source.source, defaultCategory: source.defaultCategory ) } @@ -118,8 +137,9 @@ actor MarketplaceService { // MARK: - Fetch Repository - private func fetchRepoPlugins(owner: String, repo: String, defaultCategory: String) async -> [MarketplacePlugin] { - let catalogURL = "https://raw.githubusercontent.com/\(owner)/\(repo)/main/.claude-plugin/marketplace.json" + private func fetchRepoPlugins(source: MarketplaceSource, defaultCategory: String) async -> [MarketplacePlugin] { + let ref = source.ref?.isEmpty == false ? source.ref! : "main" + let catalogURL = "https://raw.githubusercontent.com/\(source.owner)/\(source.repo)/\(ref)/.claude-plugin/marketplace.json" guard let url = URL(string: catalogURL) else { return [] } do { @@ -128,16 +148,16 @@ actor MarketplaceService { httpResponse.statusCode == 200 else { return [] } - return parseMarketplaceCatalog(data: data, owner: owner, repo: repo, defaultCategory: defaultCategory) + return parseMarketplaceCatalog(data: data, source: source, defaultCategory: defaultCategory) } catch { - logger.warning("Failed to fetch catalog from \(owner)/\(repo): \(error.localizedDescription)") + logger.warning("Failed to fetch catalog from \(source.codexSource, privacy: .public): \(error.localizedDescription)") return [] } } // MARK: - Parse Catalog - private func parseMarketplaceCatalog(data: Data, owner: String, repo: String, defaultCategory: String) -> [MarketplacePlugin] { + private func parseMarketplaceCatalog(data: Data, source: MarketplaceSource, defaultCategory: String) -> [MarketplacePlugin] { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let marketplaceName = json["name"] as? String, let plugins = json["plugins"] as? [[String: Any]] else { @@ -145,9 +165,9 @@ actor MarketplaceService { } let ownerInfo = json["owner"] as? [String: Any] - let defaultAuthor = ownerInfo?["name"] as? String ?? owner + let defaultAuthor = ownerInfo?["name"] as? String ?? source.owner - let marketplaceSource = MarketplaceSource(owner: owner, repo: repo) + let marketplaceSource = source return plugins.compactMap { entry -> MarketplacePlugin? in guard let name = entry["name"] as? String else { return nil } @@ -199,6 +219,95 @@ actor MarketplaceService { } } + // MARK: - Custom Git Sources + + func customSources() -> [MarketplaceCustomSource] { + loadCustomSources() + } + + func addCustomGitSource(url rawURL: String, ref rawRef: String? = nil) throws -> MarketplaceCustomSource { + let source = try parseGitHubSource(url: rawURL, ref: rawRef) + let normalizedSource = MarketplaceCustomSource(source: source) + var config = try loadConfig() + + let builtInSources = Self.sourceRepos.map { MarketplaceSource(owner: $0.owner, repo: $0.repo) } + if builtInSources.contains(where: { sameSource($0, source) }) || + config.customSources.contains(where: { sameSource($0.source, source) }) { + throw CustomSourceError.duplicateSource + } + + config.customSources.append(normalizedSource) + try saveConfig(config) + cachedCatalog = [] + cacheDate = nil + return normalizedSource + } + + func removeCustomGitSource(_ source: MarketplaceCustomSource) throws { + var config = try loadConfig() + config.customSources.removeAll { $0.id == source.id } + try saveConfig(config) + cachedCatalog = [] + cacheDate = nil + } + + private func loadCustomSources() -> [MarketplaceCustomSource] { + (try? loadConfig().customSources) ?? [] + } + + private func parseGitHubSource(url rawURL: String, ref rawRef: String?) throws -> MarketplaceSource { + let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + let ref = rawRef?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicitRef = ref?.isEmpty == false ? ref : nil + + if let match = regexMatch(trimmed, #"^git@github\.com:([^/\s]+)/([^/\s]+?)(?:\.git)?/?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef) + } + + if let match = regexMatch(trimmed, #"^https?://github\.com/([^/\s]+)/([^/\s]+?)(?:\.git)?/?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef) + } + + if let match = regexMatch(trimmed, #"^https?://github\.com/([^/\s]+)/([^/\s]+)/tree/([^/\s]+)(?:/.*)?$"#) { + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef ?? match[2]) + } + + if let match = regexMatch(trimmed, #"^([^/\s]+)/([^/\s@]+)(?:@([^/\s]+))?$"#) { + let parsedRef = match.count > 2 && !match[2].isEmpty ? match[2] : nil + return MarketplaceSource(owner: match[0], repo: match[1], ref: explicitRef ?? parsedRef) + } + + throw CustomSourceError.invalidGitHubURL + } + + private func regexMatch(_ value: String, _ pattern: String) -> [String]? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(value.startIndex.. Bool { + lhs.owner.lowercased() == rhs.owner.lowercased() && + lhs.repo.lowercased() == rhs.repo.lowercased() && + normalizedRef(lhs.ref) == normalizedRef(rhs.ref) + } + + private func normalizedRef(_ ref: String?) -> String { + let trimmed = ref?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed! : "main" + } + // MARK: - Installation /// Retrieve installed plugin names from RxCode state, plus legacy Claude installs. diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index b7d92c2..06a0583 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -1007,6 +1007,54 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .skillCatalogRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_catalog_request") else { return } + logger.info("[MobileSync] skill catalog requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillCatalogRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_mutation_request") else { return } + logger.info("[MobileSync] skill mutation requested operation=\(req.operation.rawValue, privacy: .public) plugin=\(req.pluginID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpRegistryRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_registry_request") else { return } + logger.info("[MobileSync] acp registry requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPRegistryRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_mutation_request") else { return } + logger.info("[MobileSync] acp mutation requested operation=\(req.operation.rawValue, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpConfigRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_config_request") else { return } + logger.info("[MobileSync] mcp config requested mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPConfigRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_mutation_request") else { return } + logger.info("[MobileSync] mcp mutation requested operation=\(req.operation.rawValue, privacy: .public) server=\(req.serverName, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .ping: guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -1197,4 +1245,10 @@ extension Notification.Name { static let mobileSyncRunProfileMutationRequested = Notification.Name("mobileSync.runProfileMutationRequested") static let mobileSyncRunProfileRunRequested = Notification.Name("mobileSync.runProfileRunRequested") static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") + static let mobileSyncSkillCatalogRequested = Notification.Name("mobileSync.skillCatalogRequested") + static let mobileSyncSkillMutationRequested = Notification.Name("mobileSync.skillMutationRequested") + static let mobileSyncACPRegistryRequested = Notification.Name("mobileSync.acpRegistryRequested") + static let mobileSyncACPMutationRequested = Notification.Name("mobileSync.acpMutationRequested") + static let mobileSyncMCPConfigRequested = Notification.Name("mobileSync.mcpConfigRequested") + static let mobileSyncMCPMutationRequested = Notification.Name("mobileSync.mcpMutationRequested") } diff --git a/RxCode/Services/NotificationService.swift b/RxCode/Services/NotificationService.swift index 851359c..d8a87d0 100644 --- a/RxCode/Services/NotificationService.swift +++ b/RxCode/Services/NotificationService.swift @@ -183,6 +183,36 @@ final class NotificationService: NSObject { } } + /// Post a local banner after a paired mobile device remotely changed the + /// desktop's skill / ACP / MCP configuration. Silently no-ops if the user + /// has not authorized notifications. + func postRemoteConfigChanged(title: String, body: String) async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + break + default: + return + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: "remote-config-\(UUID().uuidString)", + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + } catch { + logger.error("Failed to post remote config notification: \(error.localizedDescription)") + } + } + /// Post a "response complete" notification. Silently no-ops if unauthorized. /// Mobile fan-out always runs; the local macOS banner is skipped when /// `postLocalBanner` is false (e.g. the desktop app is foregrounded). diff --git a/RxCode/Services/PersistenceService.swift b/RxCode/Services/PersistenceService.swift index d30b81e..10de2df 100644 --- a/RxCode/Services/PersistenceService.swift +++ b/RxCode/Services/PersistenceService.swift @@ -239,6 +239,18 @@ actor PersistenceService { AppSupport.bundleScopedURL.appendingPathComponent("acp_registry.json") } + // MARK: - Custom Git Repositories + + func saveCustomRepos(_ repos: [CustomRepo]) throws { + let url = baseURL.appendingPathComponent("custom_repos.json") + try encode(repos, to: url) + } + + func loadCustomRepos() -> [CustomRepo] { + let url = baseURL.appendingPathComponent("custom_repos.json") + return decode([CustomRepo].self, from: url) ?? [] + } + // MARK: - GitHub User Cache func saveGitHubUser(_ user: GitHubUser) throws { diff --git a/RxCode/Views/Chat/GitHubSheet.swift b/RxCode/Views/Chat/GitHubSheet.swift index 2d72df5..6eadba6 100644 --- a/RxCode/Views/Chat/GitHubSheet.swift +++ b/RxCode/Views/Chat/GitHubSheet.swift @@ -8,18 +8,23 @@ struct GitHubSheet: View { @State private var showLoginSheet = false @State private var searchText = "" @State private var cloningRepo: String? + @State private var selectedTab = 0 + @State private var customRepoURL = "" + @State private var customRepoName = "" + @State private var isAddingCustomRepo = false + @State private var cloningCustomRepo: String? var body: some View { VStack(spacing: 0) { // Title bar HStack { - Text("GitHub") + Text("Git Repositories") .font(.headline) .foregroundStyle(ClaudeTheme.textPrimary) Spacer() - if appState.isLoggedIn, let user = appState.gitHubUser { + if selectedTab == 0, appState.isLoggedIn, let user = appState.gitHubUser { Text("@\(user.login)") .font(.caption) .foregroundStyle(ClaudeTheme.textSecondary) @@ -48,13 +53,22 @@ struct GitHubSheet: View { .padding(.horizontal, 16) .padding(.vertical, 12) + // Tab picker + Picker("Source", selection: $selectedTab) { + Text("GitHub").tag(0) + Text("Custom").tag(1) + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 8) + ClaudeThemeDivider() // Content - if appState.isLoggedIn { - repoContent + if selectedTab == 0 { + githubContent } else { - connectPrompt + customContent } } .frame(width: 480, height: 520) @@ -70,6 +84,18 @@ struct GitHubSheet: View { } } + // MARK: - GitHub Content + + private var githubContent: some View { + Group { + if appState.isLoggedIn { + repoContent + } else { + connectPrompt + } + } + } + // MARK: - Connect Prompt private var connectPrompt: some View { @@ -140,6 +166,159 @@ struct GitHubSheet: View { } } + // MARK: - Custom Content + + private var customContent: some View { + VStack(spacing: 0) { + // Add button + HStack { + Button { + withAnimation { + isAddingCustomRepo = true + } + } label: { + Label("Add Repository", systemImage: "plus") + } + .buttonStyle(ClaudeAccentButtonStyle()) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + + ClaudeThemeDivider() + + if isAddingCustomRepo { + addCustomRepoForm + } + + if appState.customRepos.isEmpty, !isAddingCustomRepo { + customEmptyState + } else { + customRepoList + } + } + } + + private var addCustomRepoForm: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Git URL") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + TextField("https://github.com/owner/repo.git or git@github.com:owner/repo.git", text: $customRepoURL) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Project Name") + .font(.caption) + .foregroundStyle(ClaudeTheme.textSecondary) + TextField("my-project", text: $customRepoName) + .textFieldStyle(.roundedBorder) + } + + HStack { + Button("Cancel") { + withAnimation { + isAddingCustomRepo = false + customRepoURL = "" + customRepoName = "" + } + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + + Spacer() + + Button { + Task { await cloneCustomRepo() } + } label: { + if cloningCustomRepo != nil { + ProgressView() + .controlSize(.small) + } else { + Text("Clone & Add") + } + } + .buttonStyle(ClaudeAccentButtonStyle()) + .disabled(customRepoURL.isEmpty || customRepoName.isEmpty || cloningCustomRepo != nil) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var customEmptyState: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "archivebox") + .font(.system(size: 32)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("No custom repositories") + .font(.subheadline) + .foregroundStyle(ClaudeTheme.textSecondary) + Text("Add a Git repository by URL") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var customRepoList: some View { + List { + ForEach(appState.customRepos) { repo in + HStack(spacing: 10) { + Image(systemName: "archivebox.fill") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(repo.name) + .font(.body) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + Text(repo.cloneURL) + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + + Spacer() + + if cloningCustomRepo == repo.name { + ProgressView() + .controlSize(.small) + } else if isCustomRepoAdded(repo) { + Label("Added", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(ClaudeTheme.statusSuccess) + } else { + Button { + Task { await cloneCustomRepo(repo) } + } label: { + Label("Clone", systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(ClaudeSecondaryButtonStyle()) + } + + Button(role: .destructive) { + Task { await appState.removeCustomRepo(repo) } + } label: { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 2) + } + } + .listStyle(.plain) + } + private var loadingState: some View { VStack(spacing: 10) { Spacer() @@ -227,6 +406,10 @@ struct GitHubSheet: View { appState.projects.contains { $0.gitHubRepo == repo.fullName } } + private func isCustomRepoAdded(_ repo: CustomRepo) -> Bool { + appState.projects.contains { $0.path.contains(repo.name) } + } + private func cloneRepo(_ repo: GitHubRepo) async { cloningRepo = repo.fullName do { @@ -237,6 +420,37 @@ struct GitHubSheet: View { } cloningRepo = nil } + + private func cloneCustomRepo(_ repo: CustomRepo? = nil) async { + if let repo { + cloningCustomRepo = repo.name + do { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + try await appState.cloneCustomRepo(repo, in: windowState) + } catch { + windowState.errorMessage = "Clone failed: \(error.localizedDescription)" + windowState.showError = true + } + cloningCustomRepo = nil + } else { + cloningCustomRepo = customRepoName + do { + try await appState.addCustomRepo(url: customRepoURL, name: customRepoName, in: windowState) + customRepoURL = "" + customRepoName = "" + isAddingCustomRepo = false + } catch { + windowState.errorMessage = "Clone failed: \(error.localizedDescription)" + windowState.showError = true + } + cloningCustomRepo = nil + } + } } #Preview { diff --git a/RxCode/Views/Chat/SkillMarketView.swift b/RxCode/Views/Chat/SkillMarketView.swift index 97355c3..d7170f8 100644 --- a/RxCode/Views/Chat/SkillMarketView.swift +++ b/RxCode/Views/Chat/SkillMarketView.swift @@ -8,6 +8,7 @@ struct SkillMarketView: View { @State private var searchText = "" @State private var selectedFilter = "All" @State private var selectedPlugin: MarketplacePlugin? + @State private var showGitSourceSheet = false /// When true, strips overlay-specific styling (rounded corners, shadow, fixed frame) /// and hides the close button so the view can be embedded in a parent container. @@ -33,6 +34,10 @@ struct SkillMarketView: View { ) .focusable(false) } + .sheet(isPresented: $showGitSourceSheet) { + AddSkillGitSourceSheet() + .focusable(false) + } } private var marketplaceContent: some View { @@ -40,6 +45,10 @@ struct SkillMarketView: View { headerBar Divider() searchAndFilterBar + if !appState.marketplaceCustomSources.isEmpty || appState.marketplaceSourceError != nil { + Divider() + customSourcesBar + } Divider() pluginGrid } @@ -58,6 +67,16 @@ struct SkillMarketView: View { Spacer() + Button { + showGitSourceSheet = true + } label: { + Label("Add Git Source", systemImage: "plus") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Add a skill catalog from a GitHub repository") + Button { Task { await appState.loadMarketplace(forceRefresh: true) } } label: { @@ -122,6 +141,45 @@ struct SkillMarketView: View { .padding(.vertical, 10) } + @ViewBuilder + private var customSourcesBar: some View { + if !appState.marketplaceCustomSources.isEmpty || appState.marketplaceSourceError != nil { + HStack(spacing: 8) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + + if appState.marketplaceCustomSources.isEmpty { + Text("No custom Git sources") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + } else { + Text("\(appState.marketplaceCustomSources.count) custom Git source\(appState.marketplaceCustomSources.count == 1 ? "" : "s")") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .foregroundStyle(.secondary) + } + + if let error = appState.marketplaceSourceError { + Text(error) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(Color.red) + .lineLimit(1) + } + + Spacer() + + Button("Manage") { + showGitSourceSheet = true + } + .font(.system(size: ClaudeTheme.size(12))) + .buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + .background(Color(NSColor.controlBackgroundColor).opacity(0.45)) + } + } + private func filterChip(_ label: String) -> some View { Button { selectedFilter = label @@ -216,6 +274,154 @@ struct SkillMarketView: View { } } +// MARK: - Add Git Source + +struct AddSkillGitSourceSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var ref = "" + @State private var isAdding = false + @State private var localError: String? + + private var canAdd: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isAdding + } + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Add Git Skill Source") + .font(.system(size: ClaudeTheme.size(16), weight: .semibold)) + Text("Use a GitHub repository that exposes .claude-plugin/marketplace.json.") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + + Divider() + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text("GitHub Repository") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + TextField("https://github.com/owner/repo", text: $gitURL) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(13))) + } + + VStack(alignment: .leading, spacing: 5) { + Text("Branch or Ref") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + TextField("main", text: $ref) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(13))) + } + + if let error = localError ?? appState.marketplaceSourceError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(Color.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button { + Task { await addSource() } + } label: { + if isAdding { + ProgressView() + .controlSize(.small) + } else { + Text("Add Source") + } + } + .buttonStyle(.borderedProminent) + .disabled(!canAdd) + } + } + .padding(20) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Custom Sources") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + if appState.marketplaceCustomSources.isEmpty { + Text("No custom Git sources added.") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } else { + ForEach(appState.marketplaceCustomSources) { source in + HStack(spacing: 10) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(source.displayName) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .lineLimit(1) + .textSelection(.enabled) + + Spacer() + + Button(role: .destructive) { + Task { await appState.removeMarketplaceGitSource(source) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } + } + } + .padding(20) + } + .frame(width: 520, height: 460) + } + + private func addSource() async { + isAdding = true + localError = nil + let ok = await appState.addMarketplaceGitSource( + url: gitURL, + ref: ref.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : ref + ) + isAdding = false + if ok { + gitURL = "" + ref = "" + } else { + localError = appState.marketplaceSourceError + } + } +} + // MARK: - Plugin Card (for grid) struct PluginCard: View { diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index 2003c74..c37457c 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -72,6 +72,44 @@ final class MobileAppState: ObservableObject { @Published var runTasks: [MobileRunTaskSnapshot] = [] @Published var inFlightRunProfileRequests: Set = [] @Published var lastRunProfileError: String? + + // MARK: - Remote desktop config: Skills + + /// Marketplace catalog mirrored from the desktop, with per-plugin install + /// state. Populated lazily when the skill screen opens. + @Published var skillCatalog: [MobileSkillPlugin] = [] + @Published var skillCatalogLoading = false + @Published var skillCatalogError: String? + /// Plugin ids with an in-flight install/uninstall request — drives per-row + /// spinners. + @Published var inFlightSkillMutations: Set = [] + @Published var lastSkillError: String? + /// The latest catalog request id, so a stale reply is discarded. + private var pendingSkillCatalogRequestID: UUID? + + // MARK: - Remote desktop config: ACP agent clients + + @Published var acpRegistryAgents: [MobileACPRegistryAgent] = [] + @Published var acpInstalledClients: [MobileACPClient] = [] + @Published var acpRegistryLoading = false + @Published var acpRegistryError: String? + /// Registry-agent ids or installed-client ids with an in-flight mutation. + @Published var inFlightACPMutations: Set = [] + @Published var lastACPError: String? + private var pendingACPRegistryRequestID: UUID? + /// Maps an ACP mutation request id to the identity key tracked in + /// `inFlightACPMutations`, so the result clears the right row. + private var acpMutationKeys: [UUID: String] = [:] + + // MARK: - Remote desktop config: MCP servers + + @Published var mcpServers: [MobileMCPServer] = [] + @Published var mcpConfigLoading = false + @Published var mcpConfigError: String? + /// Server names with an in-flight add/remove/toggle request. + @Published var inFlightMCPMutations: Set = [] + @Published var lastMCPError: String? + private var pendingMCPConfigRequestID: UUID? /// IDs of branch operations awaiting a `BranchOpResultPayload`. Used so the /// UI can render a spinner on the chip while the desktop runs git. @Published var inFlightBranchOps: Set = [] @@ -1003,6 +1041,256 @@ final class MobileAppState: ObservableObject { activeSessionID = nil pendingPermission = nil pendingQuestions = [] + skillCatalog = [] + skillCatalogLoading = false + skillCatalogError = nil + inFlightSkillMutations = [] + lastSkillError = nil + pendingSkillCatalogRequestID = nil + acpRegistryAgents = [] + acpInstalledClients = [] + acpRegistryLoading = false + acpRegistryError = nil + inFlightACPMutations = [] + lastACPError = nil + pendingACPRegistryRequestID = nil + acpMutationKeys = [:] + mcpServers = [] + mcpConfigLoading = false + mcpConfigError = nil + inFlightMCPMutations = [] + lastMCPError = nil + pendingMCPConfigRequestID = nil + } + + // MARK: - Remote desktop configuration + + /// Timeout after which a stuck remote config request is cleared and an + /// error surfaced. ACP installs download a binary, so they get longer. + private static let remoteConfigTimeout: Duration = .seconds(20) + private static let acpInstallTimeout: Duration = .seconds(90) + + /// Runs `perform` on the main actor after `timeout`. Callers use it to + /// expire a request that never received a reply (relay dropped, etc.). + private func scheduleTimeout( + _ timeout: Duration, + perform: @escaping (MobileAppState) -> Void + ) { + Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + perform(self) + } + } + + // Skills + + func requestSkillCatalog(forceRefresh: Bool = false) async { + guard isPaired else { + skillCatalogError = "Connect a Mac to browse skills." + return + } + let payload = SkillCatalogRequestPayload(forceRefresh: forceRefresh) + pendingSkillCatalogRequestID = payload.clientRequestID + skillCatalogLoading = true + skillCatalogError = nil + do { + try await client.send(.skillCatalogRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingSkillCatalogRequestID == payload.clientRequestID { + s.pendingSkillCatalogRequestID = nil + s.skillCatalogLoading = false + s.skillCatalogError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillCatalogLoading = false + skillCatalogError = "Failed to request skills: \(error.localizedDescription)" + if pendingSkillCatalogRequestID == payload.clientRequestID { + pendingSkillCatalogRequestID = nil + } + } + } + + func installSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .install) + } + + func uninstallSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .uninstall) + } + + private func mutateSkill(_ pluginID: String, operation: SkillMutationRequestPayload.Operation) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillMutations.contains(pluginID) else { return } + let payload = SkillMutationRequestPayload(operation: operation, pluginID: pluginID) + inFlightSkillMutations.insert(pluginID) + lastSkillError = nil + do { + try await client.send(.skillMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightSkillMutations.remove(pluginID) != nil { + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightSkillMutations.remove(pluginID) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + + // ACP agent clients + + func requestACPRegistry(forceRefresh: Bool = false) async { + guard isPaired else { + acpRegistryError = "Connect a Mac to manage agents." + return + } + let payload = ACPRegistryRequestPayload(forceRefresh: forceRefresh) + pendingACPRegistryRequestID = payload.clientRequestID + acpRegistryLoading = true + acpRegistryError = nil + do { + try await client.send(.acpRegistryRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingACPRegistryRequestID == payload.clientRequestID { + s.pendingACPRegistryRequestID = nil + s.acpRegistryLoading = false + s.acpRegistryError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpRegistryLoading = false + acpRegistryError = "Failed to request agents: \(error.localizedDescription)" + if pendingACPRegistryRequestID == payload.clientRequestID { + pendingACPRegistryRequestID = nil + } + } + } + + func installACPAgent(_ registryAgentID: String) async { + await mutateACP(operation: .install, key: registryAgentID, registryAgentID: registryAgentID) + } + + func uninstallACPClient(_ clientID: String) async { + await mutateACP(operation: .uninstall, key: clientID, clientID: clientID) + } + + func setACPClientEnabled(_ clientID: String, enabled: Bool) async { + await mutateACP(operation: .setEnabled, key: clientID, clientID: clientID, enabled: enabled) + } + + private func mutateACP( + operation: ACPMutationRequestPayload.Operation, + key: String, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastACPError = "Connect a Mac first." + return + } + guard !inFlightACPMutations.contains(key) else { return } + let payload = ACPMutationRequestPayload( + operation: operation, + registryAgentID: registryAgentID, + clientID: clientID, + enabled: enabled + ) + inFlightACPMutations.insert(key) + acpMutationKeys[payload.clientRequestID] = key + lastACPError = nil + let timeout = operation == .install ? Self.acpInstallTimeout : Self.remoteConfigTimeout + do { + try await client.send(.acpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(timeout) { s in + if s.acpMutationKeys.removeValue(forKey: payload.clientRequestID) != nil { + s.inFlightACPMutations.remove(key) + s.lastACPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightACPMutations.remove(key) + lastACPError = "Failed to send request: \(error.localizedDescription)" + } + } + + // MCP servers + + func requestMCPConfig() async { + guard isPaired else { + mcpConfigError = "Connect a Mac to manage MCP servers." + return + } + let payload = MCPConfigRequestPayload() + pendingMCPConfigRequestID = payload.clientRequestID + mcpConfigLoading = true + mcpConfigError = nil + do { + try await client.send(.mcpConfigRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingMCPConfigRequestID == payload.clientRequestID { + s.pendingMCPConfigRequestID = nil + s.mcpConfigLoading = false + s.mcpConfigError = "Request timed out. Check your Mac and try again." + } + } + } catch { + mcpConfigLoading = false + mcpConfigError = "Failed to request MCP servers: \(error.localizedDescription)" + if pendingMCPConfigRequestID == payload.clientRequestID { + pendingMCPConfigRequestID = nil + } + } + } + + func addMCPServer(_ server: MobileMCPServer) async { + await mutateMCP(operation: .add, serverName: server.name, server: server) + } + + func removeMCPServer(_ serverName: String) async { + await mutateMCP(operation: .remove, serverName: serverName) + } + + func setMCPServerEnabled(_ serverName: String, enabled: Bool) async { + await mutateMCP(operation: .setEnabled, serverName: serverName, enabled: enabled) + } + + private func mutateMCP( + operation: MCPMutationRequestPayload.Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastMCPError = "Connect a Mac first." + return + } + guard !inFlightMCPMutations.contains(serverName) else { return } + let payload = MCPMutationRequestPayload( + operation: operation, + serverName: serverName, + server: server, + enabled: enabled + ) + inFlightMCPMutations.insert(serverName) + lastMCPError = nil + do { + try await client.send(.mcpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightMCPMutations.remove(serverName) != nil { + s.lastMCPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightMCPMutations.remove(serverName) + lastMCPError = "Failed to send request: \(error.localizedDescription)" + } } // MARK: - Inbound events @@ -1177,6 +1465,24 @@ final class MobileAppState: ObservableObject { case .runTaskUpdate(let update): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } upsertRunTask(update.task) + case .skillCatalogResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_catalog_result") else { return } + applySkillCatalogResult(result) + case .skillMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_mutation_result") else { return } + applySkillMutationResult(result) + case .acpRegistryResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_registry_result") else { return } + applyACPRegistryResult(result) + case .acpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_mutation_result") else { return } + applyACPMutationResult(result) + case .mcpConfigResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_config_result") else { return } + applyMCPConfigResult(result) + case .mcpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_mutation_result") else { return } + applyMCPMutationResult(result) case .ping: guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } @@ -1202,6 +1508,76 @@ final class MobileAppState: ObservableObject { } } + private func applySkillCatalogResult(_ result: SkillCatalogResultPayload) { + guard pendingSkillCatalogRequestID == result.clientRequestID else { return } + pendingSkillCatalogRequestID = nil + skillCatalogLoading = false + if result.ok { + skillCatalog = result.plugins + skillCatalogError = nil + } else { + skillCatalogError = result.errorMessage ?? "Failed to load skills." + } + } + + private func applySkillMutationResult(_ result: SkillMutationResultPayload) { + inFlightSkillMutations.remove(result.pluginID) + skillCatalog = result.plugins + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill operation failed." + } + } + + private func applyACPRegistryResult(_ result: ACPRegistryResultPayload) { + guard pendingACPRegistryRequestID == result.clientRequestID else { return } + pendingACPRegistryRequestID = nil + acpRegistryLoading = false + if result.ok { + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + acpRegistryError = nil + } else { + acpRegistryError = result.errorMessage ?? "Failed to load the agent registry." + } + } + + private func applyACPMutationResult(_ result: ACPMutationResultPayload) { + if let key = acpMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightACPMutations.remove(key) + } + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + if result.ok { + lastACPError = nil + } else { + lastACPError = result.errorMessage ?? "Agent operation failed." + } + } + + private func applyMCPConfigResult(_ result: MCPConfigResultPayload) { + guard pendingMCPConfigRequestID == result.clientRequestID else { return } + pendingMCPConfigRequestID = nil + mcpConfigLoading = false + if result.ok { + mcpServers = result.servers + mcpConfigError = nil + } else { + mcpConfigError = result.errorMessage ?? "Failed to load MCP servers." + } + } + + private func applyMCPMutationResult(_ result: MCPMutationResultPayload) { + inFlightMCPMutations.remove(result.serverName) + mcpServers = result.servers + if result.ok { + lastMCPError = nil + } else { + lastMCPError = result.errorMessage ?? "MCP operation failed." + } + } + private func upsertRunTask(_ task: MobileRunTaskSnapshot) { if let idx = runTasks.firstIndex(where: { $0.taskId == task.taskId }) { runTasks[idx] = task diff --git a/RxCodeMobile/Views/MobileACPClientsView.swift b/RxCodeMobile/Views/MobileACPClientsView.swift new file mode 100644 index 0000000..be1df58 --- /dev/null +++ b/RxCodeMobile/Views/MobileACPClientsView.swift @@ -0,0 +1,180 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Manage ACP agent clients on the paired desktop: toggle/remove installed +/// clients and install new ones from the agentclientprotocol.com registry. +struct MobileACPClientsView: View { + @EnvironmentObject private var state: MobileAppState + @State private var pendingUninstall: MobileACPClient? + + var body: some View { + List { + if let error = state.acpRegistryError { + errorRow(error) + } + if let error = state.lastACPError { + errorRow(error) + } + + installedSection + registrySection + } + .navigationTitle("Agent Clients") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if state.acpRegistryLoading { + ProgressView() + } + } + } + .refreshable { + await state.requestACPRegistry(forceRefresh: true) + } + .task { + if state.acpRegistryAgents.isEmpty, state.acpInstalledClients.isEmpty { + await state.requestACPRegistry() + } + } + .alert( + "Remove agent?", + isPresented: Binding( + get: { pendingUninstall != nil }, + set: { if !$0 { pendingUninstall = nil } } + ) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let client = pendingUninstall { + Task { await state.uninstallACPClient(client.id) } + } + pendingUninstall = nil + } + } message: { + if let client = pendingUninstall { + Text("This removes \(client.displayName) and its downloaded binary from your Mac.") + } + } + } + + @ViewBuilder + private var installedSection: some View { + if !state.acpInstalledClients.isEmpty { + Section("Installed") { + ForEach(state.acpInstalledClients) { client in + installedRow(client) + } + } + } + } + + private func installedRow(_ client: MobileACPClient) -> some View { + HStack(spacing: 12) { + agentIcon(client.iconURL) + VStack(alignment: .leading, spacing: 2) { + Text(client.displayName).font(.headline) + Text("\(client.launchKind) · \(client.modelCount) model\(client.modelCount == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if state.inFlightACPMutations.contains(client.id) { + ProgressView() + } else { + Toggle("", isOn: Binding( + get: { client.enabled }, + set: { value in Task { await state.setACPClientEnabled(client.id, enabled: value) } } + )) + .labelsHidden() + } + } + .swipeActions { + Button("Remove", role: .destructive) { + pendingUninstall = client + } + } + } + + @ViewBuilder + private var registrySection: some View { + let available = state.acpRegistryAgents.filter { !$0.isInstalled } + if !available.isEmpty { + Section("Available in Registry") { + ForEach(available) { agent in + registryRow(agent) + } + } + } else if state.acpInstalledClients.isEmpty, + !state.acpRegistryLoading, + state.acpRegistryError == nil { + Section { + Text("No agents found.").foregroundStyle(.secondary) + } + } + } + + private func registryRow(_ agent: MobileACPRegistryAgent) -> some View { + HStack(spacing: 12) { + agentIcon(agent.iconURL) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(agent.name).font(.headline) + Text(agent.version).font(.caption).foregroundStyle(.tertiary) + } + if !agent.summary.isEmpty { + Text(agent.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + if let dist = distributionLabel(agent) { + Text(dist).font(.caption2).foregroundStyle(.tertiary) + } + } + Spacer() + if state.inFlightACPMutations.contains(agent.id) { + VStack(spacing: 2) { + ProgressView() + Text("Installing…").font(.caption2).foregroundStyle(.secondary) + } + } else { + Button("Install") { + Task { await state.installACPAgent(agent.id) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(.vertical, 2) + } + + private func distributionLabel(_ agent: MobileACPRegistryAgent) -> String? { + var kinds: [String] = [] + if agent.hasBinary { kinds.append("binary") } + if agent.hasNpx { kinds.append("npx") } + if agent.hasUvx { kinds.append("uvx") } + return kinds.isEmpty ? nil : kinds.joined(separator: " · ") + } + + private func agentIcon(_ urlString: String?) -> some View { + Group { + if let urlString, let url = URL(string: urlString) { + AsyncImage(url: url) { image in + image.resizable().scaledToFit() + } placeholder: { + Image(systemName: "cpu").foregroundStyle(.secondary) + } + } else { + Image(systemName: "cpu").foregroundStyle(.secondary) + } + } + .frame(width: 28, height: 28) + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } +} diff --git a/RxCodeMobile/Views/MobileMCPServersView.swift b/RxCodeMobile/Views/MobileMCPServersView.swift new file mode 100644 index 0000000..fa9e7f9 --- /dev/null +++ b/RxCodeMobile/Views/MobileMCPServersView.swift @@ -0,0 +1,308 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Manage the global MCP servers configured on the paired desktop. +struct MobileMCPServersView: View { + @EnvironmentObject private var state: MobileAppState + @State private var editing: MobileMCPServer? + @State private var showingAdd = false + @State private var pendingRemoval: MobileMCPServer? + + var body: some View { + List { + if let error = state.mcpConfigError { + errorRow(error) + } + if let error = state.lastMCPError { + errorRow(error) + } + + if state.mcpServers.isEmpty { + emptyOrLoadingRow + } else { + ForEach(state.mcpServers) { server in + row(server) + } + } + } + .navigationTitle("MCP Servers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAdd = true + } label: { + Image(systemName: "plus") + } + } + } + .refreshable { + await state.requestMCPConfig() + } + .task { + if state.mcpServers.isEmpty { + await state.requestMCPConfig() + } + } + .sheet(isPresented: $showingAdd) { + MobileMCPServerFormView(existing: nil) + .environmentObject(state) + } + .sheet(item: $editing) { server in + MobileMCPServerFormView(existing: server) + .environmentObject(state) + } + .alert( + "Remove MCP server?", + isPresented: Binding( + get: { pendingRemoval != nil }, + set: { if !$0 { pendingRemoval = nil } } + ) + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let server = pendingRemoval { + Task { await state.removeMCPServer(server.name) } + } + pendingRemoval = nil + } + } message: { + if let server = pendingRemoval { + Text("This removes \(server.name) from your Mac's global MCP configuration.") + } + } + } + + private func row(_ server: MobileMCPServer) -> some View { + Button { + editing = server + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(server.name) + .font(.headline) + .foregroundStyle(.primary) + Text(server.transport.uppercased()) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15), in: Capsule()) + .foregroundStyle(.secondary) + } + if !server.endpoint.isEmpty { + Text(server.endpoint) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + if state.inFlightMCPMutations.contains(server.name) { + ProgressView() + } else { + Toggle("", isOn: Binding( + get: { server.isGloballyEnabled }, + set: { value in Task { await state.setMCPServerEnabled(server.name, enabled: value) } } + )) + .labelsHidden() + } + } + } + .swipeActions { + Button("Remove", role: .destructive) { + pendingRemoval = server + } + } + } + + @ViewBuilder + private var emptyOrLoadingRow: some View { + if state.mcpConfigLoading { + HStack(spacing: 8) { + ProgressView() + Text("Loading…").foregroundStyle(.secondary) + } + } else if state.mcpConfigError == nil { + Text("No MCP servers configured. Tap + to add one.") + .foregroundStyle(.secondary) + } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } +} + +/// Add or edit a global MCP server. The desktop upserts by name, so editing an +/// existing server reuses the add path; the name field is locked when editing. +struct MobileMCPServerFormView: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + + let existing: MobileMCPServer? + + @State private var name = "" + @State private var transport = "stdio" + @State private var command = "" + @State private var url = "" + @State private var args: [DraftField] = [] + @State private var env: [DraftPair] = [] + @State private var headers: [DraftPair] = [] + + private struct DraftField: Identifiable { + let id = UUID() + var value: String + } + + private struct DraftPair: Identifiable { + let id = UUID() + var key: String + var value: String + } + + private var isStdio: Bool { transport == "stdio" } + + private var canSave: Bool { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if isStdio { + return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("Name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .disabled(existing != nil) + Picker("Transport", selection: $transport) { + Text("stdio").tag("stdio") + Text("HTTP").tag("http") + Text("SSE").tag("sse") + } + } + + if isStdio { + Section("Command") { + TextField("Command", text: $command) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + Section("Arguments") { + ForEach($args) { $field in + TextField("Argument", text: $field.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + .onDelete { args.remove(atOffsets: $0) } + Button { + args.append(DraftField(value: "")) + } label: { + Label("Add Argument", systemImage: "plus") + } + } + keyValueSection(title: "Environment Variables", items: $env, addLabel: "Add Variable") + } else { + Section("Endpoint") { + TextField("URL", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + } + keyValueSection(title: "Headers", items: $headers, addLabel: "Add Header") + } + } + .navigationTitle(existing == nil ? "Add MCP Server" : "Edit MCP Server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!canSave) + } + } + .onAppear(perform: load) + } + } + + @ViewBuilder + private func keyValueSection( + title: String, + items: Binding<[DraftPair]>, + addLabel: String + ) -> some View { + Section(title) { + ForEach(items) { $pair in + HStack(spacing: 8) { + TextField("Key", text: $pair.key) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + Divider() + TextField("Value", text: $pair.value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + } + .onDelete { items.wrappedValue.remove(atOffsets: $0) } + Button { + items.wrappedValue.append(DraftPair(key: "", value: "")) + } label: { + Label(addLabel, systemImage: "plus") + } + } + } + + private func load() { + guard let existing else { return } + name = existing.name + transport = existing.transport + command = existing.command ?? "" + url = existing.url ?? "" + args = existing.args.map { DraftField(value: $0) } + env = existing.env.map { DraftPair(key: $0.key, value: $0.value) } + headers = existing.headers.map { DraftPair(key: $0.key, value: $0.value) } + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanArgs = args + .map { $0.value.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let cleanEnv = env.compactMap { pair -> MobileMCPKeyValue? in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : MobileMCPKeyValue(key: key, value: pair.value) + } + let cleanHeaders = headers.compactMap { pair -> MobileMCPKeyValue? in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : MobileMCPKeyValue(key: key, value: pair.value) + } + let endpoint = isStdio + ? ([trimmedCommand] + cleanArgs).filter { !$0.isEmpty }.joined(separator: " ") + : trimmedURL + + let server = MobileMCPServer( + name: trimmedName, + transport: transport, + url: isStdio ? nil : trimmedURL, + command: isStdio ? trimmedCommand : nil, + args: isStdio ? cleanArgs : [], + env: isStdio ? cleanEnv : [], + headers: isStdio ? [] : cleanHeaders, + isGloballyEnabled: existing?.isGloballyEnabled ?? true, + endpoint: endpoint + ) + Task { await state.addMCPServer(server) } + dismiss() + } +} diff --git a/RxCodeMobile/Views/MobileSettingsView.swift b/RxCodeMobile/Views/MobileSettingsView.swift index 522c931..449ca64 100644 --- a/RxCodeMobile/Views/MobileSettingsView.swift +++ b/RxCodeMobile/Views/MobileSettingsView.swift @@ -35,6 +35,10 @@ struct MobileSettingsView: View { } } + if state.isPaired { + desktopConfigurationSection + } + pairNewSection } .navigationTitle("Settings") @@ -150,6 +154,33 @@ struct MobileSettingsView: View { } } + /// Links to the remote desktop-management screens: skill marketplace, ACP + /// agent clients, and MCP servers. Shown only while paired since every + /// action targets the active Mac. + private var desktopConfigurationSection: some View { + Section { + NavigationLink { + MobileSkillMarketView() + } label: { + Label("Skills", systemImage: "puzzlepiece.extension") + } + NavigationLink { + MobileACPClientsView() + } label: { + Label("Agent Clients", systemImage: "cpu") + } + NavigationLink { + MobileMCPServersView() + } label: { + Label("MCP Servers", systemImage: "server.rack") + } + } header: { + Text("Desktop Configuration") + } footer: { + Text("Install skills and agents, and configure MCP servers on the active Mac.") + } + } + private var pairNewSection: some View { Section { Button { diff --git a/RxCodeMobile/Views/MobileSkillMarketView.swift b/RxCodeMobile/Views/MobileSkillMarketView.swift new file mode 100644 index 0000000..1f3f7bb --- /dev/null +++ b/RxCodeMobile/Views/MobileSkillMarketView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import RxCodeCore +import RxCodeSync + +/// Browse the paired desktop's skill marketplace and install or remove skills +/// remotely. The catalog is fetched lazily when the screen first opens. +struct MobileSkillMarketView: View { + @EnvironmentObject private var state: MobileAppState + @State private var searchText = "" + + var body: some View { + List { + if let error = state.skillCatalogError { + errorRow(error) + } + if let error = state.lastSkillError { + errorRow(error) + } + + if state.skillCatalog.isEmpty { + emptyOrLoadingRow + } else { + ForEach(groupedCategories, id: \.self) { category in + Section(category) { + ForEach(plugins(in: category)) { plugin in + row(plugin) + } + } + } + } + } + .navigationTitle("Skills") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search skills") + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if state.skillCatalogLoading { + ProgressView() + } + } + } + .refreshable { + await state.requestSkillCatalog(forceRefresh: true) + } + .task { + if state.skillCatalog.isEmpty { + await state.requestSkillCatalog() + } + } + } + + private var filteredPlugins: [MobileSkillPlugin] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return state.skillCatalog } + return state.skillCatalog.filter { + $0.name.lowercased().contains(query) + || $0.summary.lowercased().contains(query) + || $0.categoryLabel.lowercased().contains(query) + } + } + + /// Distinct category labels in display order (alphabetical). + private var groupedCategories: [String] { + Set(filteredPlugins.map(\.categoryLabel)).sorted() + } + + private func plugins(in category: String) -> [MobileSkillPlugin] { + filteredPlugins + .filter { $0.categoryLabel == category } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + @ViewBuilder + private var emptyOrLoadingRow: some View { + if state.skillCatalogLoading { + HStack(spacing: 8) { + ProgressView() + Text("Loading skills…").foregroundStyle(.secondary) + } + } else if state.skillCatalogError == nil { + Text("No skills found.").foregroundStyle(.secondary) + } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } + + private func row(_ plugin: MobileSkillPlugin) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(plugin.name).font(.headline) + Spacer() + control(plugin) + } + if !plugin.summary.isEmpty { + Text(plugin.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Text(plugin.marketplaceLabel) + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 2) + } + + @ViewBuilder + private func control(_ plugin: MobileSkillPlugin) -> some View { + if state.inFlightSkillMutations.contains(plugin.id) { + ProgressView() + } else if plugin.isInstalled { + Button("Remove", role: .destructive) { + Task { await state.uninstallSkill(plugin.id) } + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button("Install") { + Task { await state.installSkill(plugin.id) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } +} diff --git a/RxCodeWidget/RxCodeJobActivity.swift b/RxCodeWidget/RxCodeJobActivity.swift index 529a235..284fac5 100644 --- a/RxCodeWidget/RxCodeJobActivity.swift +++ b/RxCodeWidget/RxCodeJobActivity.swift @@ -74,14 +74,33 @@ struct RxCodeJobActivityAttributes: ActivityAttributes { /// `Date` so a pushed content-state decodes without a date strategy. var updatedAt: Double + /// Client-side normalized job list keyed by the chat-session id. APNs + /// updates can race a foreground local start, so renderers should read + /// this list instead of the raw wire array. + var deduplicatedJobs: [Job] { + var ordered: [Job] = [] + var indicesByID: [String: Int] = [:] + for job in jobs { + if let index = indicesByID[job.id] { + ordered[index] = job + } else { + indicesByID[job.id] = ordered.count + ordered.append(job) + } + } + return ordered + } + /// Jobs still being worked on. - var runningJobs: [Job] { jobs.filter { $0.phase == .running } } + var runningJobs: [Job] { deduplicatedJobs.filter { $0.phase == .running } } /// Jobs that have finished. - var doneJobs: [Job] { jobs.filter { $0.phase == .done } } + var doneJobs: [Job] { deduplicatedJobs.filter { $0.phase == .done } } /// Count of jobs still running — the headline number for the UI. var runningCount: Int { runningJobs.count } + /// Count of unique jobs represented by this activity. + var jobCount: Int { deduplicatedJobs.count } /// `true` once every tracked job has finished. - var allDone: Bool { !jobs.isEmpty && runningJobs.isEmpty } + var allDone: Bool { !deduplicatedJobs.isEmpty && runningJobs.isEmpty } /// The single job to feature when exactly one is running, else `nil`. var soleRunningJob: Job? { runningJobs.count == 1 ? runningJobs.first : nil diff --git a/RxCodeWidget/RxCodeWidgetLiveActivity.swift b/RxCodeWidget/RxCodeWidgetLiveActivity.swift index 61bb4cc..703a8a2 100644 --- a/RxCodeWidget/RxCodeWidgetLiveActivity.swift +++ b/RxCodeWidget/RxCodeWidgetLiveActivity.swift @@ -76,7 +76,7 @@ private func leadingIcon(_ state: JobState) -> String { private func headlineTitle(_ state: JobState) -> String { if let job = state.soleRunningJob { return job.title } if state.allDone { - return state.jobs.count == 1 ? "Job done" : "\(state.jobs.count) jobs done" + return state.jobCount == 1 ? "Job done" : "\(state.jobCount) jobs done" } return "\(state.runningCount) jobs running" } @@ -147,7 +147,7 @@ private struct JobsLockScreenView: View { SoleJobView(job: job) } else if state.allDone { VStack(alignment: .leading, spacing: 8) { - Text("\(state.jobs.count) \(state.jobs.count == 1 ? "job" : "jobs") completed") + Text("\(state.jobCount) \(state.jobCount == 1 ? "job" : "jobs") completed") .font(.subheadline.weight(.medium)) StepProgressBar(done: 1, total: 0, isComplete: true) } From 9b23052c3dc9f5def43ed96a5fedeabdc4088bcd Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 02:07:42 +0800 Subject: [PATCH 7/7] feat: add skill ui to both mobile and desktop app --- .../Sources/RxCodeSync/Protocol/Payload.swift | 85 ++++++++- RxCode/App/AppState.swift | 87 ++++++++- RxCode/Services/MarketplaceService.swift | 1 - RxCode/Services/MobileSyncService.swift | 9 + RxCode/Views/Chat/SkillMarketView.swift | 49 +++-- RxCodeMobile/State/MobileAppState.swift | 83 ++++++++ .../Views/MobileSkillMarketView.swift | 179 +++++++++++++++++- RxCodeMobile/Views/ThreadChangesSheet.swift | 2 +- 8 files changed, 465 insertions(+), 30 deletions(-) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 09ead8c..1a826dd 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -49,6 +49,8 @@ public enum Payload: Sendable { case skillCatalogResult(SkillCatalogResultPayload) case skillMutationRequest(SkillMutationRequestPayload) case skillMutationResult(SkillMutationResultPayload) + case skillSourceMutationRequest(SkillSourceMutationRequestPayload) + case skillSourceMutationResult(SkillSourceMutationResultPayload) case acpRegistryRequest(ACPRegistryRequestPayload) case acpRegistryResult(ACPRegistryResultPayload) case acpMutationRequest(ACPMutationRequestPayload) @@ -107,6 +109,8 @@ public extension Payload { case .skillCatalogResult: return "skill_catalog_result" case .skillMutationRequest: return "skill_mutation_request" case .skillMutationResult: return "skill_mutation_result" + case .skillSourceMutationRequest: return "skill_source_mutation_request" + case .skillSourceMutationResult: return "skill_source_mutation_result" case .acpRegistryRequest: return "acp_registry_request" case .acpRegistryResult: return "acp_registry_result" case .acpMutationRequest: return "acp_mutation_request" @@ -764,22 +768,35 @@ public struct MobileSkillPlugin: Codable, Sendable, Identifiable, Equatable { } } +public struct MobileSkillSource: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + public struct SkillCatalogResultPayload: Codable, Sendable { public let clientRequestID: UUID public let ok: Bool public let errorMessage: String? public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] public init( clientRequestID: UUID, ok: Bool, errorMessage: String? = nil, - plugins: [MobileSkillPlugin] = [] + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] ) { self.clientRequestID = clientRequestID self.ok = ok self.errorMessage = errorMessage self.plugins = plugins + self.sources = sources } } @@ -810,6 +827,7 @@ public struct SkillMutationResultPayload: Codable, Sendable { public let ok: Bool public let errorMessage: String? public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] public init( clientRequestID: UUID, @@ -817,7 +835,8 @@ public struct SkillMutationResultPayload: Codable, Sendable { pluginID: String, ok: Bool, errorMessage: String? = nil, - plugins: [MobileSkillPlugin] = [] + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] ) { self.clientRequestID = clientRequestID self.operation = operation @@ -825,6 +844,62 @@ public struct SkillMutationResultPayload: Codable, Sendable { self.ok = ok self.errorMessage = errorMessage self.plugins = plugins + self.sources = sources + } +} + +public struct SkillSourceMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + } + + public let clientRequestID: UUID + public let operation: Operation + public let sourceID: String? + public let gitURL: String? + public let ref: String? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.gitURL = gitURL + self.ref = ref + } +} + +public struct SkillSourceMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillSourceMutationRequestPayload.Operation + public let sourceID: String? + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources } } @@ -2026,6 +2101,8 @@ extension Payload: Codable { case skillCatalogResult = "skill_catalog_result" case skillMutationRequest = "skill_mutation_request" case skillMutationResult = "skill_mutation_result" + case skillSourceMutationRequest = "skill_source_mutation_request" + case skillSourceMutationResult = "skill_source_mutation_result" case acpRegistryRequest = "acp_registry_request" case acpRegistryResult = "acp_registry_result" case acpMutationRequest = "acp_mutation_request" @@ -2088,6 +2165,8 @@ extension Payload: Codable { case .skillCatalogResult: self = .skillCatalogResult(try container.decode(SkillCatalogResultPayload.self, forKey: .data)) case .skillMutationRequest: self = .skillMutationRequest(try container.decode(SkillMutationRequestPayload.self, forKey: .data)) case .skillMutationResult: self = .skillMutationResult(try container.decode(SkillMutationResultPayload.self, forKey: .data)) + case .skillSourceMutationRequest: self = .skillSourceMutationRequest(try container.decode(SkillSourceMutationRequestPayload.self, forKey: .data)) + case .skillSourceMutationResult: self = .skillSourceMutationResult(try container.decode(SkillSourceMutationResultPayload.self, forKey: .data)) case .acpRegistryRequest: self = .acpRegistryRequest(try container.decode(ACPRegistryRequestPayload.self, forKey: .data)) case .acpRegistryResult: self = .acpRegistryResult(try container.decode(ACPRegistryResultPayload.self, forKey: .data)) case .acpMutationRequest: self = .acpMutationRequest(try container.decode(ACPMutationRequestPayload.self, forKey: .data)) @@ -2146,6 +2225,8 @@ extension Payload: Codable { case .skillCatalogResult(let p): try container.encode(TypeKey.skillCatalogResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .skillMutationRequest(let p): try container.encode(TypeKey.skillMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .skillMutationResult(let p): try container.encode(TypeKey.skillMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillSourceMutationRequest(let p): try container.encode(TypeKey.skillSourceMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) + case .skillSourceMutationResult(let p): try container.encode(TypeKey.skillSourceMutationResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .acpRegistryRequest(let p): try container.encode(TypeKey.acpRegistryRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .acpRegistryResult(let p): try container.encode(TypeKey.acpRegistryResult.rawValue, forKey: .type); try container.encode(p, forKey: .data) case .acpMutationRequest(let p): try container.encode(TypeKey.acpMutationRequest.rawValue, forKey: .type); try container.encode(p, forKey: .data) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index a81effe..7ca7b34 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1361,6 +1361,20 @@ final class AppState { } mobileSyncObservers.append(skillMutationObserver) + let skillSourceMutationObserver = center.addObserver( + forName: .mobileSyncSkillSourceMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillSourceMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillSourceMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillSourceMutationObserver) + let acpRegistryObserver = center.addObserver( forName: .mobileSyncACPRegistryRequested, object: nil, @@ -1899,13 +1913,21 @@ final class AppState { } } + private func mobileSkillSources() async -> [MobileSkillSource] { + await marketplace.customSources().map { source in + MobileSkillSource(id: source.id, displayName: source.displayName) + } + } + private func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) + let sources = await mobileSkillSources() let result = SkillCatalogResultPayload( clientRequestID: request.clientRequestID, ok: true, errorMessage: nil, - plugins: plugins + plugins: plugins, + sources: sources ) await MobileSyncService.shared.send(.skillCatalogResult(result), toHex: fromHex) } @@ -1919,7 +1941,8 @@ final class AppState { pluginID: request.pluginID, ok: false, errorMessage: "Skill not found in the marketplace catalog.", - plugins: await mobileSkillPlugins() + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() ) await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) return @@ -1958,11 +1981,69 @@ final class AppState { pluginID: request.pluginID, ok: ok, errorMessage: errorMessage, - plugins: await mobileSkillPlugins() + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() ) await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) } + private func handleMobileSkillSourceMutationRequest(_ request: SkillSourceMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var sourceID = request.sourceID + var bannerTitle: String? + var bannerBody: String? + + do { + switch request.operation { + case .add: + guard let gitURL = request.gitURL, + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw MobileRemoteConfigError.invalidRequest("Missing Git repository URL.") + } + let source = try await marketplace.addCustomGitSource(url: gitURL, ref: request.ref) + sourceID = source.id + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source added remotely" + bannerBody = source.displayName + case .remove: + let currentSources = await marketplace.customSources() + guard let sourceID = request.sourceID, + let source = currentSources.first(where: { $0.id == sourceID }) else { + throw MobileRemoteConfigError.invalidRequest("Skill Git source not found.") + } + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source removed remotely" + bannerBody = source.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill source mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + let plugins = await mobileSkillPlugins(forceRefresh: true) + let sources = await mobileSkillSources() + marketplaceCatalog = await marketplace.fetchCatalog() + marketplaceInstalledNames = await marketplace.installedPluginNames() + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = SkillSourceMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + sourceID: sourceID, + ok: ok, + errorMessage: errorMessage, + plugins: plugins, + sources: sources + ) + await MobileSyncService.shared.send(.skillSourceMutationResult(result), toHex: fromHex) + } + private func mobileACPRegistryAgents() -> [MobileACPRegistryAgent] { let installedRegistryIDs = Set(acpClients.compactMap(\.registryId)) return (acpRegistry?.agents ?? []).map { agent in diff --git a/RxCode/Services/MarketplaceService.swift b/RxCode/Services/MarketplaceService.swift index c34f9f1..0bb1683 100644 --- a/RxCode/Services/MarketplaceService.swift +++ b/RxCode/Services/MarketplaceService.swift @@ -33,7 +33,6 @@ actor MarketplaceService { ("anthropics", "skills", "agent-skills"), ("anthropics", "knowledge-work-plugins", "knowledge-work"), ("anthropics", "financial-services-plugins", "financial-services"), - ("rxtech-lab", "agent-skills", "rxlab-skills"), ] private static let openAISkillsSource = MarketplaceSource(owner: "openai", repo: "skills") diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 06a0583..47e9d1c 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -1023,6 +1023,14 @@ final class MobileSyncService: ObservableObject { object: nil, userInfo: ["from": inbound.fromHex, "payload": req] ) + case .skillSourceMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_source_mutation_request") else { return } + logger.info("[MobileSync] skill source mutation requested operation=\(req.operation.rawValue, privacy: .public) source=\(req.sourceID ?? req.gitURL ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillSourceMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) case .acpRegistryRequest(let req): guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_registry_request") else { return } logger.info("[MobileSync] acp registry requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") @@ -1247,6 +1255,7 @@ extension Notification.Name { static let mobileSyncRunProfileStopRequested = Notification.Name("mobileSync.runProfileStopRequested") static let mobileSyncSkillCatalogRequested = Notification.Name("mobileSync.skillCatalogRequested") static let mobileSyncSkillMutationRequested = Notification.Name("mobileSync.skillMutationRequested") + static let mobileSyncSkillSourceMutationRequested = Notification.Name("mobileSync.skillSourceMutationRequested") static let mobileSyncACPRegistryRequested = Notification.Name("mobileSync.acpRegistryRequested") static let mobileSyncACPMutationRequested = Notification.Name("mobileSync.acpMutationRequested") static let mobileSyncMCPConfigRequested = Notification.Name("mobileSync.mcpConfigRequested") diff --git a/RxCode/Views/Chat/SkillMarketView.swift b/RxCode/Views/Chat/SkillMarketView.swift index d7170f8..0121f5f 100644 --- a/RxCode/Views/Chat/SkillMarketView.swift +++ b/RxCode/Views/Chat/SkillMarketView.swift @@ -375,34 +375,41 @@ struct AddSkillGitSourceSheet: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) } else { - ForEach(appState.marketplaceCustomSources) { source in - HStack(spacing: 10) { - Image(systemName: "point.3.connected.trianglepath.dotted") - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(.secondary) - .frame(width: 16) - - Text(source.displayName) - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - .lineLimit(1) - .textSelection(.enabled) - - Spacer() - - Button(role: .destructive) { - Task { await appState.removeMarketplaceGitSource(source) } - } label: { - Image(systemName: "trash") + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(appState.marketplaceCustomSources) { source in + HStack(spacing: 10) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 16) + + Text(source.displayName) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .lineLimit(1) + .textSelection(.enabled) + + Spacer() + + Button(role: .destructive) { + Task { await appState.removeMarketplaceGitSource(source) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) } - .buttonStyle(.borderless) } - .padding(.vertical, 4) } } } .padding(20) + // Fill the remaining vertical space so the list scrolls internally + // instead of pushing the pinned header off the top of the sheet. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(width: 520, height: 460) + .frame(width: 520, height: 560) } private func addSource() async { diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index c37457c..561ca63 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -80,12 +80,15 @@ final class MobileAppState: ObservableObject { @Published var skillCatalog: [MobileSkillPlugin] = [] @Published var skillCatalogLoading = false @Published var skillCatalogError: String? + @Published var skillSources: [MobileSkillSource] = [] /// Plugin ids with an in-flight install/uninstall request — drives per-row /// spinners. @Published var inFlightSkillMutations: Set = [] + @Published var inFlightSkillSourceMutations: Set = [] @Published var lastSkillError: String? /// The latest catalog request id, so a stale reply is discarded. private var pendingSkillCatalogRequestID: UUID? + private var skillSourceMutationKeys: [UUID: String] = [:] // MARK: - Remote desktop config: ACP agent clients @@ -1044,9 +1047,12 @@ final class MobileAppState: ObservableObject { skillCatalog = [] skillCatalogLoading = false skillCatalogError = nil + skillSources = [] inFlightSkillMutations = [] + inFlightSkillSourceMutations = [] lastSkillError = nil pendingSkillCatalogRequestID = nil + skillSourceMutationKeys = [:] acpRegistryAgents = [] acpInstalledClients = [] acpRegistryLoading = false @@ -1120,6 +1126,26 @@ final class MobileAppState: ObservableObject { await mutateSkill(pluginID, operation: .uninstall) } + func addSkillGitSource(url: String, ref: String?) async { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { + lastSkillError = "Enter a GitHub repository URL." + return + } + let trimmedRef = ref?.trimmingCharacters(in: .whitespacesAndNewlines) + let key = "add:\(trimmedURL)" + await mutateSkillSource( + key: key, + operation: .add, + gitURL: trimmedURL, + ref: trimmedRef?.isEmpty == false ? trimmedRef : nil + ) + } + + func removeSkillGitSource(_ sourceID: String) async { + await mutateSkillSource(key: sourceID, operation: .remove, sourceID: sourceID) + } + private func mutateSkill(_ pluginID: String, operation: SkillMutationRequestPayload.Operation) async { guard isPaired else { lastSkillError = "Connect a Mac first." @@ -1142,6 +1168,42 @@ final class MobileAppState: ObservableObject { } } + private func mutateSkillSource( + key: String, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillSourceMutations.contains(key) else { return } + let payload = SkillSourceMutationRequestPayload( + operation: operation, + sourceID: sourceID, + gitURL: gitURL, + ref: ref + ) + inFlightSkillSourceMutations.insert(key) + skillSourceMutationKeys[payload.clientRequestID] = key + lastSkillError = nil + do { + try await client.send(.skillSourceMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if let key = s.skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) { + s.inFlightSkillSourceMutations.remove(key) + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightSkillSourceMutations.remove(key) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + // ACP agent clients func requestACPRegistry(forceRefresh: Bool = false) async { @@ -1471,6 +1533,9 @@ final class MobileAppState: ObservableObject { case .skillMutationResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_mutation_result") else { return } applySkillMutationResult(result) + case .skillSourceMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_source_mutation_result") else { return } + applySkillSourceMutationResult(result) case .acpRegistryResult(let result): guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_registry_result") else { return } applyACPRegistryResult(result) @@ -1514,6 +1579,7 @@ final class MobileAppState: ObservableObject { skillCatalogLoading = false if result.ok { skillCatalog = result.plugins + skillSources = result.sources skillCatalogError = nil } else { skillCatalogError = result.errorMessage ?? "Failed to load skills." @@ -1523,6 +1589,7 @@ final class MobileAppState: ObservableObject { private func applySkillMutationResult(_ result: SkillMutationResultPayload) { inFlightSkillMutations.remove(result.pluginID) skillCatalog = result.plugins + skillSources = result.sources if result.ok { lastSkillError = nil } else { @@ -1530,6 +1597,22 @@ final class MobileAppState: ObservableObject { } } + private func applySkillSourceMutationResult(_ result: SkillSourceMutationResultPayload) { + if let key = skillSourceMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightSkillSourceMutations.remove(key) + } + if let sourceID = result.sourceID { + inFlightSkillSourceMutations.remove(sourceID) + } + skillCatalog = result.plugins + skillSources = result.sources + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill source operation failed." + } + } + private func applyACPRegistryResult(_ result: ACPRegistryResultPayload) { guard pendingACPRegistryRequestID == result.clientRequestID else { return } pendingACPRegistryRequestID = nil diff --git a/RxCodeMobile/Views/MobileSkillMarketView.swift b/RxCodeMobile/Views/MobileSkillMarketView.swift index 1f3f7bb..705d737 100644 --- a/RxCodeMobile/Views/MobileSkillMarketView.swift +++ b/RxCodeMobile/Views/MobileSkillMarketView.swift @@ -7,6 +7,8 @@ import RxCodeSync struct MobileSkillMarketView: View { @EnvironmentObject private var state: MobileAppState @State private var searchText = "" + @State private var selectedFilter = "All" + @State private var showingGitSourceSheet = false var body: some View { List { @@ -19,6 +21,8 @@ struct MobileSkillMarketView: View { if state.skillCatalog.isEmpty { emptyOrLoadingRow + } else if filteredPlugins.isEmpty { + Text("No skills found.").foregroundStyle(.secondary) } else { ForEach(groupedCategories, id: \.self) { category in Section(category) { @@ -35,6 +39,17 @@ struct MobileSkillMarketView: View { .autocorrectionDisabled(true) .textInputAutocapitalization(.never) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + filterMenu + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showingGitSourceSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add Git Source") + } ToolbarItem(placement: .topBarTrailing) { if state.skillCatalogLoading { ProgressView() @@ -49,16 +64,80 @@ struct MobileSkillMarketView: View { await state.requestSkillCatalog() } } + .sheet(isPresented: $showingGitSourceSheet) { + MobileSkillGitSourceSheet() + .environmentObject(state) + } } private var filteredPlugins: [MobileSkillPlugin] { + var plugins = state.skillCatalog + + if selectedFilter == "Installed" { + plugins = plugins.filter(\.isInstalled) + } else if selectedFilter != "All" { + plugins = plugins.filter { $0.marketplaceLabel == selectedFilter } + } + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !query.isEmpty else { return state.skillCatalog } - return state.skillCatalog.filter { + guard !query.isEmpty else { return plugins } + return plugins.filter { $0.name.lowercased().contains(query) || $0.summary.lowercased().contains(query) || $0.categoryLabel.lowercased().contains(query) + || $0.marketplaceLabel.lowercased().contains(query) + } + } + + private var availableMarketplaces: [String] { + var counts: [String: Int] = [:] + for plugin in state.skillCatalog { + counts[plugin.marketplaceLabel, default: 0] += 1 + } + return counts.sorted { $0.value > $1.value }.map(\.key) + } + + private var filterMenu: some View { + Menu { + Button { + selectedFilter = "All" + } label: { + if selectedFilter == "All" { + Label("All", systemImage: "checkmark") + } else { + Text("All") + } + } + + Button { + selectedFilter = "Installed" + } label: { + if selectedFilter == "Installed" { + Label("Installed", systemImage: "checkmark") + } else { + Text("Installed") + } + } + + if !availableMarketplaces.isEmpty { + Section("Marketplaces") { + ForEach(availableMarketplaces, id: \.self) { label in + Button { + selectedFilter = label + } label: { + if selectedFilter == label { + Label(label, systemImage: "checkmark") + } else { + Text(label) + } + } + } + } + } + } label: { + Image(systemName: selectedFilter == "All" ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") } + .accessibilityLabel("Filter Skills") } /// Distinct category labels in display order (alphabetical). @@ -129,3 +208,99 @@ struct MobileSkillMarketView: View { } } } + +private struct MobileSkillGitSourceSheet: View { + @EnvironmentObject private var state: MobileAppState + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var ref = "" + + private var canAdd: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !state.inFlightSkillSourceMutations.contains(addKey) + } + + private var addKey: String { + "add:\(gitURL.trimmingCharacters(in: .whitespacesAndNewlines))" + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("https://github.com/owner/repo", text: $gitURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + TextField("main", text: $ref) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } header: { + Text("Git Source") + } footer: { + Text("Use a GitHub repository that exposes .claude-plugin/marketplace.json.") + } + + if let error = state.lastSkillError { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.orange) + } + } + + Section("Custom Sources") { + if state.skillSources.isEmpty { + Text("No custom Git sources added.") + .foregroundStyle(.secondary) + } else { + ForEach(state.skillSources) { source in + HStack { + Text(source.displayName) + .lineLimit(1) + Spacer() + if state.inFlightSkillSourceMutations.contains(source.id) { + ProgressView() + } else { + Button(role: .destructive) { + Task { await state.removeSkillGitSource(source.id) } + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(source.displayName)") + } + } + } + } + } + } + .navigationTitle("Git Sources") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let submittedKey = addKey + await state.addSkillGitSource(url: gitURL, ref: ref) + if state.inFlightSkillSourceMutations.contains(submittedKey) { + gitURL = "" + ref = "" + } + } + } label: { + if state.inFlightSkillSourceMutations.contains(addKey) { + ProgressView() + } else { + Text("Add") + } + } + .disabled(!canAdd) + } + } + } + } +} diff --git a/RxCodeMobile/Views/ThreadChangesSheet.swift b/RxCodeMobile/Views/ThreadChangesSheet.swift index d163c46..8e6341e 100644 --- a/RxCodeMobile/Views/ThreadChangesSheet.swift +++ b/RxCodeMobile/Views/ThreadChangesSheet.swift @@ -161,7 +161,7 @@ struct ThreadChangesSheet: View { gitSection("Unstaged", changes.filter { $0.kind == .unstaged }) gitSection("Untracked", changes.filter { $0.kind == .untracked }) } - .listStyle(.insetGrouped) + .listStyle(.plain) .refreshable { await load() } } }