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
89 changes: 83 additions & 6 deletions Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,18 @@
"state" : "new",
"value" : "Code review will start in %1$lld %2$@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "코드 리뷰가 %lld %@ 후에 시작됩니다"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "代码审查将在 %lld %@ 后开始"
}
}
}
},
Expand Down Expand Up @@ -4628,10 +4640,36 @@
}
},
"Review cancelled" : {

"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리뷰 취소됨"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "审查已取消"
}
}
}
},
"Review started" : {

"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리뷰 시작됨"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "审查已开始"
}
}
}
},
"Rewind to a previous point" : {
"localizations" : {
Expand Down Expand Up @@ -5402,10 +5440,36 @@
}
},
"Start it now" : {

"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "지금 시작"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "立即开始"
}
}
}
},
"Stop Review" : {

"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리뷰 중지"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止审查"
}
}
}
},
"Subagent" : {
"localizations" : {
Expand Down Expand Up @@ -6114,7 +6178,20 @@
}
},
"Waiting to start review" : {

"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리뷰 시작 대기 중"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "等待开始审查"
}
}
}
},
"What should we build in %@?" : {
"localizations" : {
Expand Down Expand Up @@ -6206,4 +6283,4 @@
}
},
"version" : "1.1"
}
}
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ public protocol HookController: AnyObject {
func reviewRound(sessionId: String) -> Int
/// Set the failed-review round counter for a session.
func setReviewRound(_ round: Int, sessionId: String)
/// The previous failing review's feedback for a session, carried into the
/// next review so it can focus on whether those issues were fixed instead of
/// reviewing from scratch. `nil` when the next review should start fresh.
func lastReviewFeedback(sessionId: String) -> String?
/// Store (or clear, with `nil`) the failing review feedback to carry into the
/// session's next review turn.
func setLastReviewFeedback(_ feedback: String?, sessionId: String)
/// Feed a failing code review's feedback back into the reviewed thread as a
/// bounded auto-continue fix turn (rendered as an auto-continue card, not a
/// user message). The thread runs the Code Review hook again when the fix
Expand Down Expand Up @@ -267,6 +274,10 @@ public protocol HookController: AnyObject {
/// Whether the thread recorded any file edits (gates "Commit Files").
func threadHasFileChanges(sessionId: String) -> Bool

/// Enabled user-defined custom menu items for `surface`, scoped to `projectId`
/// (plus the "all projects" items). Backs the `CustomMenuHook`.
func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord]

/// Centralized presentation actions used by hook-supplied context menus.
func requestSecretsSetup(project: Project)
func requestSecretsDownload(project: Project)
Expand Down
12 changes: 10 additions & 2 deletions Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ public struct NewChatStartPayload: Codable, Sendable {
public struct ThreadContextMenuPayload: Codable, Sendable {
public let project: Project
public let session: ChatSession.Summary
/// Locale (language code, e.g. "ko", "zh-Hans") to render built-in item titles
/// in. `nil` => the desktop's own system locale (native menus). A mobile relay
/// request passes the phone's locale so titles come back translated.
public let locale: String?

public init(project: Project, session: ChatSession.Summary) {
public init(project: Project, session: ChatSession.Summary, locale: String? = nil) {
self.project = project
self.session = session
self.locale = locale
}
}

Expand All @@ -67,10 +72,13 @@ public struct ProjectContextMenuPayload: Codable, Sendable {
/// Pull Request — target this branch instead of the project's current branch.
/// `nil` for the project list / sidebar, which act on the current branch.
public let branch: String?
/// Locale to render built-in item titles in (see `ThreadContextMenuPayload`).
public let locale: String?

public init(project: Project, branch: String? = nil) {
public init(project: Project, branch: String? = nil, locale: String? = nil) {
self.project = project
self.branch = branch
self.locale = locale
}
}

Expand Down
57 changes: 56 additions & 1 deletion Packages/Sources/RxCodeCore/Menu/MenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,20 @@ public struct MenuActionCommand: Codable, Sendable, Hashable {
case threadCodeReview // review one thread's changes
case threadCommitFiles // commit only this thread's files
case threadStopCodeReview // stop an in-flight code review for the thread

// User-defined custom menu items (configured in Settings). The work to
// perform is carried by `custom`; the desktop dispatches it.
case custom
}

public let kind: Kind
public let projectId: UUID?
public let sessionId: String?
public let branch: String?
/// Payload for `.custom` commands. The desktop builds the menu item (and
/// substitutes context placeholders) so this carries a fully-resolved request
/// the dispatcher can perform directly. `nil` for every built-in `kind`.
public let custom: CustomMenuActionConfig?
/// Whether dispatching this command performs long-running asynchronous work
/// (e.g. pushing a branch and opening a pull request). When `true`, the side
/// that runs the command shows a blocking loading dialog and waits for it to
Expand All @@ -117,19 +125,22 @@ public struct MenuActionCommand: Codable, Sendable, Hashable {
projectId: UUID? = nil,
sessionId: String? = nil,
branch: String? = nil,
isAsync: Bool = false
isAsync: Bool = false,
custom: CustomMenuActionConfig? = nil
) {
self.kind = kind
self.projectId = projectId
self.sessionId = sessionId
self.branch = branch
self.isAsync = isAsync
self.custom = custom
}

/// Status line shown in the loading dialog while an `isAsync` command runs.
public var progressStatus: LocalizedStringKey {
switch kind {
case .projectCreatePullRequest: return "Creating pull request…"
case .custom where custom?.kind == .callAPI: return "Calling…"
default: return "Working…"
}
}
Expand All @@ -144,6 +155,50 @@ public struct MenuActionCommand: Codable, Sendable, Hashable {
sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId)
branch = try container.decodeIfPresent(String.self, forKey: .branch)
isAsync = try container.decodeIfPresent(Bool.self, forKey: .isAsync) ?? false
custom = try container.decodeIfPresent(CustomMenuActionConfig.self, forKey: .custom)
}
}

/// A serializable, fully-resolved description of a user-defined custom menu
/// action. The desktop builds the menu item from the user's `CustomMenuItemRecord`
/// (substituting context placeholders such as `{{branch}}`), so by the time this
/// crosses the relay or reaches the dispatcher every field is concrete. The
/// dispatcher (`AppState.dispatchMenuCommand`) performs the work on the desktop.
public struct CustomMenuActionConfig: Codable, Sendable, Hashable {
public enum Kind: String, Codable, Sendable {
case callAPI // perform an HTTP request
case createThread // start a new thread seeded with `message`
case continueThread // send `message` to `targetSessionId`
}

public let kind: Kind

// callAPI
public let httpMethod: String? // "GET" / "POST" / …
public let url: String?
public let headers: [String: String]?
public let body: String?

// createThread / continueThread
public let message: String?
public let targetSessionId: String? // continueThread only

public init(
kind: Kind,
httpMethod: String? = nil,
url: String? = nil,
headers: [String: String]? = nil,
body: String? = nil,
message: String? = nil,
targetSessionId: String? = nil
) {
self.kind = kind
self.httpMethod = httpMethod
self.url = url
self.headers = headers
self.body = body
self.message = message
self.targetSessionId = targetSessionId
}
}

Expand Down
110 changes: 110 additions & 0 deletions Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation
import SwiftData

/// A user-defined context-menu entry, configured in the desktop Settings view and
/// persisted on the Mac (the single source of truth). The desktop's `CustomMenuHook`
/// turns each enabled record into a serializable `MenuItem` for the matching
/// surface; mobile fetches the same items over the relay and renders them
/// identically, so no separate sync channel is needed.
///
/// `surface` selects where the item appears (project / thread / briefing-card menu),
/// `projectId == nil` means "all projects", and `actionKind` + the action fields
/// describe the work the desktop performs when the item is tapped. Template fields
/// (`urlString`, `bodyTemplate`, `messageTemplate`, header values) may contain
/// context placeholders such as `{{projectName}}`, `{{projectPath}}`,
/// `{{gitHubRepo}}`, `{{branch}}`, `{{sessionId}}`, substituted at menu-build time.
@Model
public final class CustomMenuItemRecord {
/// Which menu surface this item attaches to.
public enum Surface: String, Codable, Sendable, CaseIterable {
case project // generic project menu (sidebar / project list)
case thread // a thread's context menu
case briefing // a branch-scoped briefing card menu
}

/// The action performed when the item is tapped.
public enum ActionKind: String, Codable, Sendable, CaseIterable {
case callAPI
case createThread
case continueThread
}

@Attribute(.unique) public var id: String
/// User-entered display title (plain text — not localized).
public var title: String
/// Optional SF Symbol name shown beside the title.
public var systemImage: String?
/// `nil` => available in every project; otherwise scoped to this project.
public var projectId: UUID?
/// Raw `Surface` value.
public var surface: String
/// Raw `ActionKind` value.
public var actionKind: String

// callAPI
public var httpMethod: String? // "GET" / "POST" / …
public var urlString: String?
/// JSON-encoded `[String: String]` of header name → value (values may template).
public var headersJSON: String?
public var bodyTemplate: String?

// createThread / continueThread
public var messageTemplate: String?
/// Target thread id for `continueThread`.
public var targetSessionId: String?

public var isEnabled: Bool
public var sortOrder: Int
public var createdAt: Date
public var updatedAt: Date

public init(
id: String = UUID().uuidString,
title: String,
systemImage: String? = nil,
projectId: UUID? = nil,
surface: Surface,
actionKind: ActionKind,
httpMethod: String? = nil,
urlString: String? = nil,
headersJSON: String? = nil,
bodyTemplate: String? = nil,
messageTemplate: String? = nil,
targetSessionId: String? = nil,
isEnabled: Bool = true,
sortOrder: Int = 0,
createdAt: Date = .now,
updatedAt: Date = .now
) {
self.id = id
self.title = title
self.systemImage = systemImage
self.projectId = projectId
self.surface = surface.rawValue
self.actionKind = actionKind.rawValue
self.httpMethod = httpMethod
self.urlString = urlString
self.headersJSON = headersJSON
self.bodyTemplate = bodyTemplate
self.messageTemplate = messageTemplate
self.targetSessionId = targetSessionId
self.isEnabled = isEnabled
self.sortOrder = sortOrder
self.createdAt = createdAt
self.updatedAt = updatedAt
}

public var surfaceValue: Surface { Surface(rawValue: surface) ?? .project }
public var actionKindValue: ActionKind { ActionKind(rawValue: actionKind) ?? .createThread }

/// Decoded header map (empty when unset or malformed).
public var headers: [String: String] {
guard let headersJSON, let data = headersJSON.data(using: .utf8) else { return [:] }
return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
}

public static func encodeHeaders(_ headers: [String: String]) -> String? {
guard !headers.isEmpty, let data = try? JSONEncoder().encode(headers) else { return nil }
return String(data: data, encoding: .utf8)
}
}
Loading
Loading