From c5a27338d46c18b18500757e91cca0db98dbffe2 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:03:18 +0800 Subject: [PATCH 1/2] feat: add custom context menus guide to User Guide and fix Help menu Add a Custom Context Menus section to the in-app User Guide and wire the macOS Help menu to open it directly, replacing the broken default Help item that reported "Help isn't available for RxCode". - Add `customContextMenus` case + bundled markdown resource to UserManualView - Add `init(initialSection:)` deep-link support and `UserGuideRequest` - Replace `.help` command group via new DocumentationCommands (opens the guide, with a direct entry for the Custom Context Menus section) - Present the guide from MainView via `AppState.userGuideRequest` - Add docs/features/custom-context-menus.md docs page Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/App/AppState.swift | 4 + RxCode/App/DocumentationCommands.swift | 26 ++++ RxCode/App/RxCodeApp.swift | 1 + .../user_manual_custom_context_menus.md | 99 +++++++++++++ RxCode/Views/MainView.swift | 3 + RxCode/Views/UserManualView.swift | 17 ++- docs/features/custom-context-menus.md | 134 ++++++++++++++++++ 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 RxCode/App/DocumentationCommands.swift create mode 100644 RxCode/Resources/user_manual_custom_context_menus.md create mode 100644 docs/features/custom-context-menus.md diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 75b3c60b..64e2619a 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -942,6 +942,10 @@ final class AppState { /// Set when a hook/context-menu action wants to open the global docs-capable /// search overlay in the current window. var docsSearchRequest: UUID? + /// Non-nil while the bundled User Guide should be presented, optionally at a + /// specific section. Set from the Help menu; `MainView` consumes it to show + /// `UserManualView`. + var userGuideRequest: UserGuideRequest? /// One-shot: when a docs-setup chat is kicked off, this holds the project so /// `DocsHook.onSessionStart` injects the docs skill into exactly that chat's /// system prompt, then clears it. diff --git a/RxCode/App/DocumentationCommands.swift b/RxCode/App/DocumentationCommands.swift new file mode 100644 index 00000000..7472c31f --- /dev/null +++ b/RxCode/App/DocumentationCommands.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Replaces the default macOS **Help** menu, which otherwise looks for a +/// (nonexistent) Help Book and reports "Help isn't available for RxCode" +/// (e.g. 未找到"RxCode"的帮助). Instead it opens the bundled, offline in-app +/// **User Guide** (`UserManualView`), including a direct entry that opens the +/// guide straight to its Custom Context Menus section. +/// +/// Selecting an item sets `appState.userGuideRequest`; `MainView` consumes it +/// and presents the guide as a sheet. +struct DocumentationCommands: Commands { + let appState: AppState + + var body: some Commands { + CommandGroup(replacing: .help) { + Button("RxCode User Guide") { + appState.userGuideRequest = UserGuideRequest() + } + .keyboardShortcut("?", modifiers: .command) + + Button("Custom Context Menus Guide") { + appState.userGuideRequest = UserGuideRequest(section: "custom_context_menus") + } + } + } +} diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 2d47a5f8..0462d7b8 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -98,6 +98,7 @@ struct RxCodeApp: App { } } AutomationCommands() + DocumentationCommands(appState: appState) } // Dedicated project window — opened on double-click diff --git a/RxCode/Resources/user_manual_custom_context_menus.md b/RxCode/Resources/user_manual_custom_context_menus.md new file mode 100644 index 00000000..7aa1528c --- /dev/null +++ b/RxCode/Resources/user_manual_custom_context_menus.md @@ -0,0 +1,99 @@ +# Custom Context Menus + +Custom context menus let you add your own items to RxCode's right-click menus. +Each item runs an action — an HTTP request, or seeding a new / existing chat +thread — and can be scoped to specific projects, attached to one or more menu +surfaces, and gated by a Swift "show condition" that decides whether it appears. + +You configure items on the Mac (the single source of truth). The desktop renders +them natively, and the mobile app fetches the same items over the relay and +renders them identically — so there's no separate mobile setup. + +## Where to configure them + +Open the editor from either: + +- **Automation menu → Custom Context Menus** (top menu bar), or +- **Settings → General → Custom Context Menus**. + +From there you can add, edit, reorder, enable/disable, and remove items. Each row +shows the item's title, the surfaces it attaches to, and whether it has a Swift +show-condition. + +## Surfaces + +A surface is *where* the item appears. An item can attach to any combination of +the three: + +| Surface | Where it shows | +|---------|----------------| +| **Project** | The project row's context menu in the sidebar (right-click a project, or its `…` menu). | +| **Thread** | A chat thread's context menu in the sidebar. | +| **Briefing** | A branch-scoped briefing card's context menu. | + +## Scope + +- **All projects** — leave the project unset; the item appears in every project. +- **One project** — scope the item to a single project, and it appears only there. + +## Actions + +Each item performs exactly one action when tapped: + +| Action | What it does | +|--------|--------------| +| **Call API** | Sends an HTTP request. Configure the method, URL, headers, and an optional body. A non-2xx response surfaces as an error. | +| **Create Thread** | Spawns a new chat thread in the project, seeded with your message template. The spawned thread is a utility run — it does **not** trigger lifecycle hooks such as code review or auto-continue. | +| **Continue Thread** | Adds your message to an existing thread (by target thread id). | + +## Placeholders + +URLs, headers, request bodies, and message templates can use placeholders that +are substituted with the current context when the menu is built: + +| Placeholder | Value | +|-------------|-------| +| `{{projectName}}` | The project's display name | +| `{{projectPath}}` | Absolute path to the project on disk | +| `{{gitHubRepo}}` | The project's `owner/repo` (empty if none) | +| `{{branch}}` | The current branch (on briefing-card menus, the card's branch) | +| `{{sessionId}}` | The thread id (on thread menus) | + +Example URL: + +``` +https://api.example.com/projects/{{projectName}}/build?branch={{branch}} +``` + +## Show conditions + +By default every item is **always shown**. You can instead gate an item on a +Swift script that returns whether it should appear. It runs on the desktop when +the menu is built (and is warmed ahead of time for mobile). + +Write a single async function: + +```swift +func checkShowMenu(context: Context) async throws -> Bool { + // Return true to show the menu item, false to hide it. + let status = try await context.git("status", "--porcelain") + return !status.isEmpty // only show when there are uncommitted changes +} +``` + +`context` exposes the current project plus two helpers that run inside the +project directory: + +| Member | Description | +|--------|-------------| +| `projectName`, `projectPath`, `gitHubRepo`, `branch`, `sessionId` | Same values as the placeholders above. | +| `shell(_:)` | Runs a shell command in the project directory and returns its trimmed stdout. | +| `git(_:)` | Convenience wrapper that runs `git` with the given arguments in the project directory. | + +The editor refuses to save a script-gated item until it compiles, offers an +**AI generate** helper that writes the function from a plain-language +requirement, and an inline **Compile** button that surfaces diagnostics. + +> **Fail-open by design.** Every failure mode — no toolchain, compile error, +> runtime crash, a timeout, or any non-`true` output — resolves to **show** the +> item. A broken condition never silently hides a menu entry you configured. diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 44a10040..2aad3b72 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -351,6 +351,9 @@ struct MainView: View { ) .environment(appState) } + .sheet(item: Bindable(appState).userGuideRequest) { request in + UserManualView(initialSection: request.section) + } .onChange(of: appState.docsSearchRequest) { _, request in guard request != nil else { return } windowState.showGlobalSearch = true diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index 92d9be65..e6a3a469 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -2,13 +2,23 @@ import RxCodeChatKit import RxCodeCore import SwiftUI +/// One-shot request to present the bundled User Guide, optionally at a given +/// section (a `UserManualMenu` raw value). Identifiable so it drives a `.sheet`. +struct UserGuideRequest: Identifiable, Hashable { + let id = UUID() + var section: String? +} + struct UserManualView: View { @Environment(\.dismiss) private var dismiss - @State private var selectedMenu: UserManualMenu? = .overview + @State private var selectedMenu: UserManualMenu? private let documents: [UserManualMenu: BundledMarkdownDocument] - init() { + /// `initialSection` is a `UserManualMenu` raw value (e.g. + /// `"custom_context_menus"`); unknown/nil values open the overview. + init(initialSection: String? = nil) { + _selectedMenu = State(initialValue: UserManualMenu(rawValue: initialSection ?? "") ?? .overview) self.documents = Dictionary(uniqueKeysWithValues: UserManualMenu.allCases.map { menu in ( menu, @@ -125,6 +135,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case integrations case worktrees case hooks + case customContextMenus = "custom_context_menus" var id: String { rawValue } @@ -143,6 +154,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case .integrations: "MCP and ACP Clients" case .worktrees: "Git Worktrees" case .hooks: "Project Hooks" + case .customContextMenus: "Custom Context Menus" } } @@ -157,6 +169,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case .integrations: "puzzlepiece.extension" case .worktrees: "arrow.triangle.branch" case .hooks: "bolt.horizontal.circle" + case .customContextMenus: "filemenu.and.cursorarrow" } } } diff --git a/docs/features/custom-context-menus.md b/docs/features/custom-context-menus.md new file mode 100644 index 00000000..9d798c80 --- /dev/null +++ b/docs/features/custom-context-menus.md @@ -0,0 +1,134 @@ +--- +slug: features/custom-context-menus +title: Custom Context Menus +description: Define your own right-click menu items in RxCode — call an API or spawn a thread, scoped to projects and gated by Swift show-conditions, on desktop and mobile. +--- + +# Custom Context Menus + +Custom context menus let you add your own items to RxCode's right-click menus. +Each item runs an action — an HTTP request, or seeding a new / existing chat +thread — and can be scoped to specific projects, attached to one or more menu +surfaces, and gated by a Swift "show condition" that decides whether the item +appears. + +Items are configured on the Mac (the single source of truth) and persisted with +SwiftData. The desktop renders them natively; **mobile fetches the same items +over the relay and renders them identically**, so there is no separate mobile +setup. + +## Where to configure them + +Open the editor from either: + +- **Top menu bar → Automation → Custom Context Menus**, or +- **Settings → General → Custom Context Menus** section. + +From there you can add, edit, reorder, enable/disable, and remove items. Each +row shows the item's title, the surfaces it attaches to, and whether it has a +Swift show-condition. + +## Surfaces + +A surface is *where* the item appears. An item can attach to any combination of +the three: + +| Surface | Where it shows | +|---------|----------------| +| **Project** | The project row's context menu in the sidebar (right-click a project, or its `…` menu). | +| **Thread** | A chat thread's context menu in the sidebar. | +| **Briefing** | A branch-scoped briefing card's context menu. | + +Surfaces are stored comma-separated (e.g. `project,thread`), so an item can live +on several menus at once. + +## Scope + +- **All projects** — leave the project unset; the item appears in every project. +- **One project** — scope the item to a single project, and it only appears + there. + +## Actions + +Each item performs exactly one action when tapped: + +| Action | What it does | +|--------|--------------| +| **Call API** | Sends an HTTP request. Configure the method, URL, headers (a name→value map), and an optional request body. A non-2xx response surfaces as an error. | +| **Create Thread** | Spawns a new chat thread in the project, seeded with your message template. The spawned thread is a utility run — it does **not** trigger lifecycle hooks such as code review or auto-continue. | +| **Continue Thread** | Adds your message to an existing thread (by target thread id). | + +## Placeholders + +URLs, headers, request bodies, and message templates can use placeholders that +are substituted at menu-build time with the current context: + +| Placeholder | Value | +|-------------|-------| +| `{{projectName}}` | The project's display name | +| `{{projectPath}}` | Absolute path to the project on disk | +| `{{gitHubRepo}}` | The project's `owner/repo` (empty if none) | +| `{{branch}}` | The current branch (on briefing-card menus, the card's branch) | +| `{{sessionId}}` | The thread id (on thread menus) | + +Example URL: + +``` +https://api.example.com/projects/{{projectName}}/build?branch={{branch}} +``` + +## Show conditions + +By default every item is **always shown**. You can instead gate an item on a +Swift script that returns whether it should appear. This is evaluated on the +desktop at menu-build time (and warmed ahead of time for mobile so the phone's +first fetch is already accurate). + +Write a single async function: + +```swift +func checkShowMenu(context: Context) async throws -> Bool { + // Return true to show the menu item, false to hide it. + let status = try await context.git("status", "--porcelain") + return !status.isEmpty // only show when there are uncommitted changes +} +``` + +The `context` parameter exposes the current project and two helpers that run +inside the project directory: + +| Member | Description | +|--------|-------------| +| `projectName`, `projectPath`, `gitHubRepo`, `branch`, `sessionId` | Same values as the placeholders above. | +| `shell(_ command: String) async throws -> String` | Runs a shell command in the project directory and returns its trimmed stdout. Started with no startup files; `PATH` is injected so your tools still resolve; stderr is discarded. | +| `git(_ args: String...) async throws -> String` | Convenience wrapper that runs `git` with the given arguments in the project directory. | + +### How conditions are compiled and run + +The script is wrapped in a generated harness that defines `Context`, compiled +with `xcrun swiftc`, and the resulting binary is cached by a SHA-256 of its +source — so a given script compiles only once. Evaluation then just runs the +cached binary with the context passed through the environment. + +The editor refuses to save a script-gated item until it compiles, and offers an +**AI generate** helper that writes a `checkShowMenu` function from a +natural-language requirement, plus an inline **Compile** button that surfaces +compiler diagnostics. + +> **Fail-open by design.** Every failure mode — no toolchain, compile error, +> runtime crash, a 3-second timeout, or any non-`true` output — resolves to +> **show** the item. A broken condition never silently hides a menu entry you +> configured. + +Because synchronous menu building can't wait on a subprocess, a script result +that isn't cached yet shows the item *and* schedules a background evaluation; +the next time the menu opens it reflects the real result. + +## Desktop ↔ mobile + +You configure items once on the Mac. The desktop's `CustomMenuHook` turns each +enabled record into a serializable menu item for the matching surface, and the +mobile app fetches the same items over the relay and renders them identically. +When an item is tapped — on either side — the action is dispatched on the Mac +through a single shared entry point, so the work runs the same way regardless of +which device initiated it. From ab9ab285a4a08ce15cc15b33417a11666a33e524 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:24:37 +0800 Subject: [PATCH 2/2] feat: add copy device token button to mobile settings Add a borderless copy button next to each paired device in the Mobile settings tab that copies the device push token to the clipboard, with checkmark feedback. Include Korean and Simplified Chinese localizations for the new and related strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- RxCode/Resources/Localizable.xcstrings | 37 ++++++++++++++++++- RxCode/Views/Settings/MobileSettingsTab.swift | 21 +++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 9529beb9..853ee7e6 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -4498,6 +4498,9 @@ } } } + }, + "Copy device token" : { + }, "Copy install command" : { "localizations" : { @@ -4983,6 +4986,22 @@ } } }, + "Custom Context Menus Guide" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 컨텍스트 메뉴 가이드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义上下文菜单指南" + } + } + } + }, "Custom script" : { "localizations" : { "ko" : { @@ -13547,6 +13566,22 @@ } } }, + "RxCode User Guide" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 사용 가이드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 用户指南" + } + } + } + }, "RxCode.xcodeproj" : { "localizations" : { "ko" : { @@ -17403,4 +17438,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index e428c1e0..427bdf5d 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -12,6 +12,7 @@ struct MobileSettingsTab: View { @State private var showPairingSheet = false @State private var showAddRelaySheet = false @State private var testNotificationDeviceID: String? + @State private var copiedTokenDeviceID: String? @State private var testNotificationAlert: TestNotificationAlert? @State private var deviceBeingRenamed: PairedDevice? @State private var renameText: String = "" @@ -294,6 +295,7 @@ struct MobileSettingsTab: View { } } Spacer() + copyTokenButton(for: device) testNotificationButton(for: device) Button { renameText = device.displayName @@ -332,6 +334,25 @@ struct MobileSettingsTab: View { } } + @ViewBuilder + private func copyTokenButton(for device: PairedDevice) -> some View { + let token = MobileSyncService.pushToken(for: device) + let copied = copiedTokenDeviceID == device.id + Button { + guard let token, !token.isEmpty else { return } + copyToClipboard(token, feedback: Binding( + get: { copiedTokenDeviceID == device.id }, + set: { copiedTokenDeviceID = $0 ? device.id : nil } + )) + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? Color.green : Color.secondary) + } + .buttonStyle(.borderless) + .disabled(token?.isEmpty ?? true) + .help(copied ? "Copied" : "Copy device token") + } + @ViewBuilder private func testNotificationButton(for device: PairedDevice) -> some View { if testNotificationDeviceID == device.id {