Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,17 @@ public protocol HookController: AnyObject {
/// (plus the "all projects" items). Backs the `CustomMenuHook`.
func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord]

/// Whether a custom menu item passes its show condition in this context.
/// `always` items always pass; a `swiftScript` item is gated on its compiled
/// script (evaluated asynchronously and cached — a cache miss shows the item,
/// failing open). Synchronous so it fits the context-menu build path.
func shouldShowConditionalMenuItem(
_ record: CustomMenuItemRecord,
project: Project,
branch: String?,
sessionId: String?
) -> Bool

/// Centralized presentation actions used by hook-supplied context menus.
func requestSecretsSetup(project: Project)
func requestSecretsDownload(project: Project)
Expand Down
38 changes: 38 additions & 0 deletions Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ public final class CustomMenuItemRecord {
case continueThread
}

/// How the item's visibility is decided.
public enum ConditionType: String, Codable, Sendable, CaseIterable {
/// No condition — the item is always shown (the default).
case always
/// A user-authored Swift script with a
/// `func checkShowMenu(context: Context) async throws -> Bool` that returns
/// `true` to show the item. Evaluated on the desktop at menu-build time.
case swiftScript
}

@Attribute(.unique) public var id: String
/// User-entered display title (plain text — not localized).
public var title: String
Expand All @@ -55,6 +65,25 @@ public final class CustomMenuItemRecord {
/// Target thread id for `continueThread`.
public var targetSessionId: String?

// Show condition
/// Raw `ConditionType` value. Defaults to `always` so existing records — and
/// any new item without a condition — are simply always shown.
///
/// The `= always` default is also load-bearing for SwiftData lightweight
/// migration: adding a non-optional attribute to an existing entity requires a
/// default, otherwise the store fails to open and the whole container (which
/// also holds `ChatThread`) falls back to an empty in-memory store — wiping
/// all chat history on the next launch.
public var conditionType: String = ConditionType.always.rawValue
/// The Swift source for a `swiftScript` condition (the user's
/// `checkShowMenu(context:)` function). `nil` for `always`.
public var conditionScript: String?
/// Whether `conditionScript` last compiled successfully. The editor refuses to
/// save a `swiftScript` item until this is `true`, so a record on disk with a
/// script is always known-compiled. Defaulted for lightweight migration (see
/// `conditionType`).
public var conditionCompiled: Bool = false

public var isEnabled: Bool
public var sortOrder: Int
public var createdAt: Date
Expand All @@ -73,6 +102,9 @@ public final class CustomMenuItemRecord {
bodyTemplate: String? = nil,
messageTemplate: String? = nil,
targetSessionId: String? = nil,
conditionType: ConditionType = .always,
conditionScript: String? = nil,
conditionCompiled: Bool = false,
isEnabled: Bool = true,
sortOrder: Int = 0,
createdAt: Date = .now,
Expand All @@ -90,6 +122,9 @@ public final class CustomMenuItemRecord {
self.bodyTemplate = bodyTemplate
self.messageTemplate = messageTemplate
self.targetSessionId = targetSessionId
self.conditionType = conditionType.rawValue
self.conditionScript = conditionScript
self.conditionCompiled = conditionCompiled
self.isEnabled = isEnabled
self.sortOrder = sortOrder
self.createdAt = createdAt
Expand All @@ -108,6 +143,9 @@ public final class CustomMenuItemRecord {
/// First surface — kept for callers that only need a single representative value.
public var surfaceValue: Surface { surfaces.first ?? .project }
public var actionKindValue: ActionKind { ActionKind(rawValue: actionKind) ?? .createThread }
/// Decoded condition type. Falls back to `always` for empty/legacy values so an
/// item without a stored condition is always shown.
public var conditionTypeValue: ConditionType { ConditionType(rawValue: conditionType) ?? .always }

/// Encode a surface list to the stored comma-separated representation. Falls
/// back to `project` when empty so a record always has a home.
Expand Down
4 changes: 2 additions & 2 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; };
DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; };
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; };
FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; };
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; };
DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; };
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; };
Expand All @@ -65,6 +64,7 @@
FB0000040000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000020000000000000001 /* FirebaseCrashlytics */; };
FB0000070000000000000001 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000050000000000000001 /* FirebaseAnalytics */; };
FB0000080000000000000001 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0000060000000000000001 /* FirebaseCrashlytics */; };
FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -155,11 +155,11 @@
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = "<group>"; };
DF5B0DDE2FCB300100CE36F /* MobileUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MobileUnitTestPlan.xctestplan; sourceTree = "<group>"; };
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = "<group>"; };
FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = "<group>"; };
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = "<group>"; };
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = "<group>"; };
E62000002FCB000100000001 /* MemoryIntentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryIntentTests.swift; sourceTree = "<group>"; };
E67335382F7356F600FD26C7 /* RxCode.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCode.app; sourceTree = BUILT_PRODUCTS_DIR; };
FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
Expand Down
132 changes: 132 additions & 0 deletions RxCode/App/AppState+MenuConditions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#if os(macOS)
import Foundation
import os
import RxCodeCore

private let menuConditionLogger = Logger(subsystem: "com.rxlab.RxCode", category: "MenuCondition")

/// Show-condition evaluation for custom context-menu items. Menu building is
/// synchronous (SwiftUI builds context menus the instant they open), but a Swift
/// script condition has to compile and run a subprocess. So the synchronous path
/// reads `menuConditionResults`; a cache miss shows the item and triggers an async
/// evaluation that fills the cache and bumps `customMenuItemsRevision` so the next
/// menu open reflects the real result. The mobile relay path is async, so it can
/// `warmMenuConditions(...)` up front and get accurate results on the first fetch.
extension AppState {
/// Synchronous gate used by `CustomMenuHook`. Returns whether `record` should
/// appear, given its condition. `always` items and items without a script show
/// unconditionally. For a `swiftScript` item: a cached result is authoritative;
/// a miss returns `true` (show) and schedules a background evaluation.
func shouldShowConditionalMenuItem(
_ record: CustomMenuItemRecord,
project: Project,
branch: String?,
sessionId: String?
) -> Bool {
guard record.conditionTypeValue == .swiftScript,
let script = record.conditionScript, !script.isEmpty else {
menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: conditionType=\(record.conditionType, privacy: .public) scriptEmpty=\((record.conditionScript ?? "").isEmpty) -> show=true (no condition)")
return true
}

let key = menuConditionKey(record: record, project: project, branch: branch, sessionId: sessionId)
if let cached = menuConditionResults[key] {
menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: cache HIT key=\(key, privacy: .public) -> show=\(cached, privacy: .public)")
return cached
}

// Miss: show by default now, evaluate in the background, refresh on result.
let inFlight = menuConditionInFlight.contains(key)
menuConditionLogger.debug("[ContextMenuCondition] shouldShow[\(record.title, privacy: .public)]: cache MISS key=\(key, privacy: .public) inFlight=\(inFlight) -> show=true (default), scheduling eval")
if !inFlight {
menuConditionInFlight.insert(key)
let context = menuConditionContext(project: project, branch: branch, sessionId: sessionId)
Task { [weak self] in
guard let self else { return }
let result = await self.menuConditionEvaluator.evaluate(script: script, context: context)
self.storeMenuConditionResult(result, forKey: key)
}
}
return true
}

/// Eagerly evaluate every conditional item for `project` on the surface implied
/// by `branch`/`sessionId`, populating `menuConditionResults` before a
/// (synchronous) menu build. Used by the mobile relay so the phone's first
/// fetch already reflects the conditions.
func warmMenuConditions(for project: Project, branch: String?, sessionId: String?) async {
let surface: CustomMenuItemRecord.Surface = sessionId != nil
? .thread
: (branch != nil ? .briefing : .project)

let conditional = threadStore.customMenuItems(projectId: project.id, surface: surface)
.filter { $0.conditionTypeValue == .swiftScript && !($0.conditionScript ?? "").isEmpty }
guard !conditional.isEmpty else { return }

let context = menuConditionContext(project: project, branch: branch, sessionId: sessionId)
for record in conditional {
guard let script = record.conditionScript else { continue }
let key = menuConditionKey(record: record, project: project, branch: branch, sessionId: sessionId)
let result = await menuConditionEvaluator.evaluate(script: script, context: context)
menuConditionResults[key] = result
}
}

// MARK: - Editor support

/// Compile a condition script (used by the editor's Compile button). Returns
/// whether it built and, on failure, the compiler diagnostics.
func compileMenuConditionScript(_ script: String) async -> CustomMenuConditionEvaluator.CompileResult {
await menuConditionEvaluator.compile(script: script)
}

/// Generate a condition script from a natural-language requirement using the
/// app's default model (falling back to the Claude default when the configured
/// default provider isn't Claude Code).
func generateMenuConditionScript(requirement: String, project: Project?) async -> String? {
let selection = defaultModelSelection(for: project)
let model = selection.provider == .claudeCode ? selection.model : "default"
return await claude.generateConditionScript(requirement: requirement, model: model)
}

// MARK: - Helpers

private func menuConditionContext(
project: Project,
branch: String?,
sessionId: String?
) -> CustomMenuConditionEvaluator.Context {
CustomMenuConditionEvaluator.Context(
projectName: project.name,
projectPath: project.path,
gitHubRepo: project.gitHubRepo ?? "",
branch: branch ?? "",
sessionId: sessionId ?? ""
)
}

/// Cache key for one item in one context. `updatedAt` invalidates the entry
/// when the item (and thus its script) is edited.
private func menuConditionKey(
record: CustomMenuItemRecord,
project: Project,
branch: String?,
sessionId: String?
) -> String {
"\(record.id)|\(record.updatedAt.timeIntervalSince1970)|\(project.id.uuidString)|\(branch ?? "")|\(sessionId ?? "")"
}

private func storeMenuConditionResult(_ result: Bool, forKey key: String) {
menuConditionInFlight.remove(key)
let previous = menuConditionResults[key]
menuConditionResults[key] = result
// Re-render the menu when the result changes what's shown. A first-time
// `true` matches the show-by-default assumption, so it needs no refresh.
let willRefresh = previous != result && !(previous == nil && result == true)
menuConditionLogger.debug("[ContextMenuCondition] storeResult: key=\(key, privacy: .public) previous=\(previous.map(String.init(describing:)) ?? "nil", privacy: .public) result=\(result, privacy: .public) refresh=\(willRefresh) revision=\(self.customMenuItemsRevision)")
if willRefresh {
customMenuItemsRevision += 1
}
}
}
#endif
6 changes: 6 additions & 0 deletions RxCode/App/AppState+MobileAutopilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,9 @@ extension AppState {
guard let project = projects.first(where: { $0.id == body.projectId }) else {
throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.")
}
// Evaluate Swift-script show conditions up front so the phone's first
// fetch already reflects them (the menu build itself is synchronous).
await warmMenuConditions(for: project, branch: body.branch, sessionId: nil)
return try encoder.encode(AutopilotMenuResult(items: projectContextMenuItems(for: project, branch: body.branch, locale: body.locale)))

case .menuForThread:
Expand All @@ -436,6 +439,9 @@ extension AppState {
?? threadStore.fetch(id: body.sessionId)?.toSummary() else {
throw MobileRemoteConfigError.invalidRequest("No thread found for the requested id.")
}
if let project = projects.first(where: { $0.id == summary.projectId }) {
await warmMenuConditions(for: project, branch: nil, sessionId: summary.id)
}
return try encoder.encode(AutopilotMenuResult(items: threadContextMenuItems(for: summary, locale: body.locale)))

case .menuExecuteCommand:
Expand Down
11 changes: 11 additions & 0 deletions RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,17 @@
/// an app restart.
var customMenuItemsRevision: Int = 0

/// Compiles + runs user-authored Swift "show condition" scripts for custom
/// menu items. Shared across windows; results land in `menuConditionResults`.
let menuConditionEvaluator = CustomMenuConditionEvaluator()
/// Cached condition results keyed by `menuConditionKey(...)`. The synchronous
/// menu builder reads this; a cache miss shows the item and kicks off an async
/// evaluation that fills the cache and bumps `customMenuItemsRevision`.
var menuConditionResults: [String: Bool] = [:]
/// Keys with an evaluation currently in flight, so repeated menu opens don't
/// spawn duplicate evaluators before the first result lands.
var menuConditionInFlight: Set<String> = []

/// Pending permission/question prompts keyed by hook id. This mirrors the
/// per-window queues so mobile thread rows can show the same attention state.
var mobilePendingRequests: [String: PermissionRequest] = [:]
Expand Down Expand Up @@ -1289,4 +1300,4 @@
return message
}
}
}

Check warning on line 1303 in RxCode/App/AppState.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 750 (file_length)
Loading
Loading