diff --git a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings index 9a46837e..bcae3b97 100644 --- a/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings +++ b/Packages/Sources/RxCodeChatKit/Resources/Localizable.xcstrings @@ -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 %@ 后开始" + } } } }, @@ -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" : { @@ -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" : { @@ -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" : { @@ -6206,4 +6283,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 7e9b0897..42b0cbf0 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -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 @@ -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) diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index 7b01d098..b670a629 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -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 } } @@ -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 } } diff --git a/Packages/Sources/RxCodeCore/Menu/MenuItem.swift b/Packages/Sources/RxCodeCore/Menu/MenuItem.swift index 6c35dca9..0131a6d2 100644 --- a/Packages/Sources/RxCodeCore/Menu/MenuItem.swift +++ b/Packages/Sources/RxCodeCore/Menu/MenuItem.swift @@ -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 @@ -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…" } } @@ -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 } } diff --git a/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift new file mode 100644 index 00000000..ab9f6002 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift @@ -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) + } +} diff --git a/Packages/Sources/RxCodeCore/Models/HookCardRecord.swift b/Packages/Sources/RxCodeCore/Models/HookCardRecord.swift new file mode 100644 index 00000000..e00c905f --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/HookCardRecord.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftData + +/// Persisted copy of a synthetic hook *card* — a `ToolCall` a hook injects into +/// a session's message list (a code-review countdown, a commit request, a +/// send-message confirmation, an auto-continue notice, …). Hook cards are not +/// part of the agent's turn, so they never reach the CLI's jsonl transcript; +/// without this sidecar they vanish whenever a session's messages are reloaded +/// from disk. +/// +/// Unlike the older single-row `HookStatusRecord` (one "last hook" per session), +/// this keeps **one row per card**, keyed by the card's tool-call `toolId`, so +/// *every* card a hook adds survives a reload — not just the most recent one. +/// +/// The row is written at *insert* time (`isComplete == false`, a spinner) and +/// updated on completion, so a card also survives a reload that happens while a +/// long-running hook (e.g. code review) is still running. A row left in-progress +/// by a crashed/closed launch is swept to an "interrupted" state on next launch. +@Model +public final class HookCardRecord { + /// The card's tool-call id. Unique, and what a rebuilt card dedupes against + /// a still-live in-memory one so an active-stream reload won't duplicate it. + @Attribute(.unique) public var toolId: String + /// Session the card belongs to (one session has many cards). + public var sessionId: String + /// The tool-call `name` (e.g. `"Hook: Lint"`, `"Code Review Countdown"`, + /// `"Auto-continue"`) — drives how `ToolResultView` renders the card. + public var toolName: String + /// JSON-encoded `[String: JSONValue]` card input, so the card rebuilds with + /// the exact payload it was inserted with (summary text, parent key, …). + public var inputData: Data + /// The card's result text, or `nil` while it is still running (spinner). + public var result: String? + public var isError: Bool + /// False while the card is still running. + public var isComplete: Bool + /// Insertion order, so cards rebuild in the order they were added. + public var createdAt: Date + public var updatedAt: Date + + public init( + toolId: String, + sessionId: String, + toolName: String, + inputData: Data, + result: String?, + isError: Bool, + isComplete: Bool, + createdAt: Date = .now, + updatedAt: Date = .now + ) { + self.toolId = toolId + self.sessionId = sessionId + self.toolName = toolName + self.inputData = inputData + self.result = result + self.isError = isError + self.isComplete = isComplete + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index 2c573989..656630b1 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -526,7 +526,13 @@ public struct AutopilotPullRequestResult: Codable, Sendable { /// (the manual equivalent of the built-in Code Review hook). public struct AutopilotThreadBody: Codable, Sendable { public let sessionId: String - public init(sessionId: String) { self.sessionId = sessionId } + /// The phone's locale for `menuForThread` so built-in titles come back + /// translated. Ignored by `threadCreateCodeReview`. + public let locale: String? + public init(sessionId: String, locale: String? = nil) { + self.sessionId = sessionId + self.locale = locale + } } /// Result of thread-spawning project actions such as code review and commit: @@ -545,9 +551,13 @@ public struct AutopilotCodeReviewResult: Codable, Sendable { public struct AutopilotProjectMenuBody: Codable, Sendable { public let projectId: UUID public let branch: String? - public init(projectId: UUID, branch: String? = nil) { + /// The phone's locale (language code) so the desktop returns built-in menu + /// titles translated for the device, not the Mac's own system locale. + public let locale: String? + public init(projectId: UUID, branch: String? = nil, locale: String? = nil) { self.projectId = projectId self.branch = branch + self.locale = locale } } diff --git a/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift b/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift index 247a25a6..8207be6b 100644 --- a/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift +++ b/Packages/Tests/RxCodeCoreTests/MenuItemTests.swift @@ -54,6 +54,32 @@ final class MenuItemTests: XCTestCase { XCTAssertFalse(decoded.isAsync) } + func testCustomCommandRoundTripsAndDefaultsWhenAbsent() throws { + // A fully-resolved custom API command survives a JSON round trip, config + // and all, and is marked async so both platforms show the loading dialog. + let config = CustomMenuActionConfig( + kind: .callAPI, + httpMethod: "POST", + url: "https://api.example.com/notify", + headers: ["Authorization": "Bearer x"], + body: #"{"branch":"main"}"# + ) + let command = MenuActionCommand(kind: .custom, projectId: UUID(), isAsync: true, custom: config) + let data = try JSONEncoder().encode(command) + let decoded = try JSONDecoder().decode(MenuActionCommand.self, from: data) + XCTAssertEqual(decoded, command) + XCTAssertEqual(decoded.custom?.kind, .callAPI) + XCTAssertEqual(decoded.custom?.headers?["Authorization"], "Bearer x") + XCTAssertTrue(decoded.isAsync) + + // A built-in command serialized by a peer that predates `custom` still + // decodes, with `custom` defaulting to nil. + let legacy = Data(#"{"kind":"projectCommitAll"}"#.utf8) + let legacyDecoded = try JSONDecoder().decode(MenuActionCommand.self, from: legacy) + XCTAssertEqual(legacyDecoded.kind, .projectCommitAll) + XCTAssertNil(legacyDecoded.custom) + } + func testDeepLinkBuildAndParse() throws { let projectId = UUID() let url = MenuDeepLink.url(action: MenuDeepLink.releaseCreate, projectId: projectId, sessionId: "s1") diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index f04adfb5..345aa39b 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; }; 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */; }; 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */; }; + 5C3333332FCB400000000004 /* ThreadStoreHookCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3333332FCB400000000003 /* ThreadStoreHookCardTests.swift */; }; 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */; }; 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */; }; 7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0001000000000000A001 /* MockAgentBackend.swift */; }; @@ -130,6 +131,7 @@ 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; }; 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BriefingThreadRowTests.swift; sourceTree = ""; }; 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThreadStoreThreadSummaryTests.swift; sourceTree = ""; }; + 5C3333332FCB400000000003 /* ThreadStoreHookCardTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ThreadStoreHookCardTests.swift; sourceTree = ""; }; 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeneratedTextSanitizerTests.swift; sourceTree = ""; }; 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; }; 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -342,6 +344,7 @@ DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */, 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */, 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */, + 5C3333332FCB400000000003 /* ThreadStoreHookCardTests.swift */, 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */, E62000002FCB000100000001 /* MemoryIntentTests.swift */, ); @@ -795,6 +798,7 @@ DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */, 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */, 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */, + 5C3333332FCB400000000004 /* ThreadStoreHookCardTests.swift in Sources */, 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */, E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */, ); diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index 7b32ec06..12418454 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -44,13 +44,16 @@ extension AppState { ) } - func projectContextMenuItems(for project: Project, branch: String? = nil) -> [MenuItem] { - hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project, branch: branch)) + /// `locale` (language code) renders built-in titles for that language; nil => + /// the desktop's own locale (used by native menus). Mobile relay requests pass + /// the phone's locale so titles come back translated. + func projectContextMenuItems(for project: Project, branch: String? = nil, locale: String? = nil) -> [MenuItem] { + hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project, branch: branch, locale: locale)) } - func threadContextMenuItems(for session: ChatSession.Summary) -> [MenuItem] { + func threadContextMenuItems(for session: ChatSession.Summary, locale: String? = nil) -> [MenuItem] { guard let project = projects.first(where: { $0.id == session.projectId }) else { return [] } - return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session)) + return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session, locale: locale)) } // MARK: - Hook execution @@ -67,39 +70,53 @@ extension AppState { "Hook: \(name.isEmpty ? "Untitled" : name)" } - /// Rebuild the persisted "last hook" card and append it to a freshly-loaded - /// message list so the hook stays visible after a reload. No-op when there's - /// no stored hook or when the live list already contains that card (matched - /// by its tool id), so an active-stream reconcile won't duplicate it. + /// Rebuild every persisted hook card for a session and merge them back into a + /// freshly-loaded message list so the hooks stay visible after a reload — and + /// at the position where they originally ran. Cards already present in the + /// live list (matched by tool id) are skipped, so an active-stream reconcile + /// won't duplicate them. + /// + /// The transcript renders in array order, so each card is inserted at its + /// chronological spot by `createdAt` (loaded CLI messages carry real + /// timestamps): a before-session-start card stays at the top, a between-turns + /// card stays between turns, instead of every card jumping to the bottom. func messagesWithPersistedHookCard(_ messages: [ChatMessage], sessionId: String) -> [ChatMessage] { - guard let record = threadStore.loadHookStatus(sessionId: sessionId) else { return messages } - let alreadyPresent = messages.contains { message in - message.blocks.contains { $0.toolCall?.id == record.toolId } - } - guard !alreadyPresent else { return messages } - - // A still-running record (e.g. a code review in flight) rebuilds as a - // spinner: `result == nil` drives the "running" hook card in - // `ToolResultView`. It's finalized live by `completeCard` (matched on - // tool id) or swept to "interrupted" on the next launch. - let toolCall = ToolCall( - id: record.toolId, - name: Self.hookToolName(for: record.name), - input: [ - "name": .string(record.name), - "trigger": .string(record.trigger), - ], - result: record.isComplete ? record.output : nil, - isError: record.isError - ) + // Oldest first, so cards inserted in the same gap keep their relative order. + let records = threadStore.loadHookCards(sessionId: sessionId) + guard !records.isEmpty else { return messages } + var result = messages - result.append(ChatMessage( - id: UUID(), - role: .assistant, - blocks: [.toolCall(toolCall)], - isResponseComplete: record.isComplete, - timestamp: record.updatedAt - )) + for record in records { + let alreadyPresent = result.contains { message in + message.blocks.contains { $0.toolCall?.id == record.toolId } + } + guard !alreadyPresent else { continue } + + // A still-running record (e.g. a code review in flight) rebuilds as a + // spinner: `result == nil` drives the "running" hook card in + // `ToolResultView`. It's finalized live by `completeCard` (matched on + // tool id) or swept to "interrupted" on the next launch. + let input = (try? JSONDecoder().decode([String: JSONValue].self, from: record.inputData)) ?? [:] + let toolCall = ToolCall( + id: record.toolId, + name: record.toolName, + input: input, + result: record.isComplete ? record.result : nil, + isError: record.isError + ) + let card = ChatMessage( + id: UUID(), + role: .assistant, + blocks: [.toolCall(toolCall)], + isResponseComplete: record.isComplete, + timestamp: record.createdAt + ) + // Insert after the last message at or before the card's insertion + // time. Already-placed earlier cards (smaller `createdAt`) sort ahead + // of this one, so same-gap cards stay in insertion order. + let insertionIndex = result.firstIndex { $0.timestamp > record.createdAt } ?? result.endIndex + result.insert(card, at: insertionIndex) + } return result } @@ -135,13 +152,15 @@ extension AppState { // `isAutoContinueTool`) rather than a user bubble, so it reads as a // system action instead of something the user typed. The full prompt is // kept as the card's result so it stays inspectable. + let toolId = UUID().uuidString + let summary: [String: JSONValue] = [ + "summary": .string("Before Session Stop hook failed — continuing (attempt \(attempt) of \(Self.maxStopHookReprompts)).") + ] updateState(sessionKey) { state in let toolCall = ToolCall( - id: UUID().uuidString, + id: toolId, name: ToolCall.autoContinueToolName, - input: [ - "summary": .string("Before Session Stop hook failed — continuing (attempt \(attempt) of \(Self.maxStopHookReprompts)).") - ], + input: summary, result: detail, isError: false ) @@ -152,6 +171,17 @@ extension AppState { isResponseComplete: true )) } + // Persist so the auto-continue card survives an app reload (it's + // synthetic and never reaches the CLI transcript). + threadStore.upsertHookCard( + sessionId: sessionKey, + toolId: toolId, + toolName: ToolCall.autoContinueToolName, + input: summary, + result: detail, + isError: false, + isComplete: true + ) let window = WindowState() window.selectedProject = project @@ -204,13 +234,15 @@ extension AppState { // Render the auto-continue as its own card (see `ToolResultView`'s // `isAutoContinueTool`) rather than a user bubble, so it reads as a // system action instead of something the user typed. + let toolId = UUID().uuidString + let summary: [String: JSONValue] = [ + "summary": .string("Code review requested changes — continuing (attempt \(attempt) of \(Self.maxReviewFixReprompts)).") + ] updateState(sessionKey) { state in let toolCall = ToolCall( - id: UUID().uuidString, + id: toolId, name: ToolCall.autoContinueToolName, - input: [ - "summary": .string("Code review requested changes — continuing (attempt \(attempt) of \(Self.maxReviewFixReprompts)).") - ], + input: summary, result: detail, isError: false ) @@ -221,6 +253,17 @@ extension AppState { isResponseComplete: true )) } + // Persist so the auto-continue card survives an app reload (it's + // synthetic and never reaches the CLI transcript). + threadStore.upsertHookCard( + sessionId: sessionKey, + toolId: toolId, + toolName: ToolCall.autoContinueToolName, + input: summary, + result: detail, + isError: false, + isComplete: true + ) let window = WindowState() window.selectedProject = project diff --git a/RxCode/App/AppState+MenuDispatch.swift b/RxCode/App/AppState+MenuDispatch.swift index 20cd649b..23d280b3 100644 --- a/RxCode/App/AppState+MenuDispatch.swift +++ b/RxCode/App/AppState+MenuDispatch.swift @@ -8,12 +8,20 @@ enum MenuDispatchError: LocalizedError { case unknownProject case unknownThread case unresolvedBranch + case invalidCustomCommand + case invalidCustomURL + case apiStatus(Int) + case customActionFailed(String) var errorDescription: String? { switch self { case .unknownProject: return "Couldn't find the project for this menu action." case .unknownThread: return "Couldn't find the thread for this menu action." case .unresolvedBranch: return "Couldn't determine the current branch." + case .invalidCustomCommand: return "This custom menu item is missing its action configuration." + case .invalidCustomURL: return "This custom menu item has an invalid URL." + case .apiStatus(let code): return "The request failed with HTTP status \(code)." + case .customActionFailed(let message): return message } } } @@ -82,6 +90,66 @@ extension AppState { // Cancel the pending countdown and/or stop the running review thread. reviewScheduler.cancel(parentSessionKey: sessionId, reason: .contextMenu) return MenuCommandResult() + + case .custom: + return try await dispatchCustomCommand(command) + } + } + + /// Run a user-defined custom menu command. The config arrives fully resolved + /// (placeholders already substituted by `CustomMenuHook`), so this just + /// performs the work: an HTTP request, or seeding a new / existing thread. + private func dispatchCustomCommand(_ command: MenuActionCommand) async throws -> MenuCommandResult { + guard let config = command.custom else { throw MenuDispatchError.invalidCustomCommand } + switch config.kind { + case .callAPI: + try await performCustomAPICall(config) + return MenuCommandResult() + + case .createThread: + let project = try requireProject(command.projectId) + let result = try await sendCrossProject( + projectId: project.id, + threadId: nil, + prompt: config.message ?? "", + waitForResponse: false + ) + if let error = result.error { throw MenuDispatchError.customActionFailed(error) } + return MenuCommandResult(threadId: result.threadId) + + case .continueThread: + guard let sessionId = config.targetSessionId, !sessionId.isEmpty else { + throw MenuDispatchError.unknownThread + } + let result = try await sendCrossProject( + projectId: command.projectId, + threadId: sessionId, + prompt: config.message ?? "", + waitForResponse: false + ) + if let error = result.error { throw MenuDispatchError.customActionFailed(error) } + return MenuCommandResult(threadId: result.threadId) + } + } + + /// Perform the configured HTTP request, throwing on a transport error or a + /// non-2xx status so the failure surfaces in the menu error alert. + private func performCustomAPICall(_ config: CustomMenuActionConfig) async throws { + guard let urlString = config.url?.trimmingCharacters(in: .whitespacesAndNewlines), + let url = URL(string: urlString) else { + throw MenuDispatchError.invalidCustomURL + } + var request = URLRequest(url: url) + request.httpMethod = (config.httpMethod ?? "GET").uppercased() + for (name, value) in config.headers ?? [:] { + request.setValue(value, forHTTPHeaderField: name) + } + if let body = config.body, !body.isEmpty { + request.httpBody = body.data(using: .utf8) + } + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw MenuDispatchError.apiStatus(http.statusCode) } } diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift index 462fe9a1..00f2e63c 100644 --- a/RxCode/App/AppState+Messaging.swift +++ b/RxCode/App/AppState+Messaging.swift @@ -268,6 +268,10 @@ extension AppState { if !isStopHookReprompt { stopHookRepromptCounts[sessionKey] = nil reviewRoundBySession[sessionKey] = nil + // Drop the carried-over review feedback too: a fresh user turn is a + // new change, so the next review should start from scratch rather + // than chasing the last turn's findings. + lastReviewFeedbackBySession[sessionKey] = nil // A new follow-up message supersedes any pending/in-flight automatic // code review for this thread: cancel the countdown and stop the // review thread if it's already running. Auto-fix reprompts (sent diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 3f1137f9..a1967e36 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -428,7 +428,7 @@ extension AppState { guard let project = projects.first(where: { $0.id == body.projectId }) else { throw MobileRemoteConfigError.invalidRequest("No project found for the requested id.") } - return try encoder.encode(AutopilotMenuResult(items: projectContextMenuItems(for: project, branch: body.branch))) + return try encoder.encode(AutopilotMenuResult(items: projectContextMenuItems(for: project, branch: body.branch, locale: body.locale))) case .menuForThread: let body = try decodeAutopilotBody(request, as: AutopilotThreadBody.self) @@ -436,7 +436,7 @@ extension AppState { ?? threadStore.fetch(id: body.sessionId)?.toSummary() else { throw MobileRemoteConfigError.invalidRequest("No thread found for the requested id.") } - return try encoder.encode(AutopilotMenuResult(items: threadContextMenuItems(for: summary))) + return try encoder.encode(AutopilotMenuResult(items: threadContextMenuItems(for: summary, locale: body.locale))) case .menuExecuteCommand: // Run a tapped command through the same dispatcher the desktop uses, diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 5cd970a6..d041b03a 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1133,6 +1133,13 @@ final class AppState { /// review→fix→review loop (see `CodeReviewHook`). Keyed by session id. var reviewRoundBySession: [String: Int] = [:] + /// The most recent failing review's feedback per session. Carried into the + /// next review turn (after an auto-fix) so the reviewer can focus on whether + /// the previously-flagged issues were addressed instead of reviewing from + /// scratch — making the re-review faster. Cleared when review passes or the + /// user sends a real message. Keyed by session id. + var lastReviewFeedbackBySession: [String: String] = [:] + func runProfiles(for projectId: UUID) -> [RunProfile] { runProfilesByProject[projectId] ?? [] } @@ -1268,6 +1275,7 @@ final class AppState { // commit, create PR) lead the context menu, followed by the autopilot // setup items (secrets, docs, release, CI). hookManager.register(ActionsMenuHook()) + hookManager.register(CustomMenuHook()) hookManager.register(AutopilotSecretsHook()) hookManager.register(AutopilotDocsHook()) hookManager.register(AutopilotReleaseHook()) diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index dfd07d64..279d3a13 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1037,7 +1037,20 @@ } }, "Add a Code Review hook and a second agent reviews every change before you ship it." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 훅을 추가하면 두 번째 에이전트가 배포 전에 모든 변경 사항을 검토합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加代码审查 Hook,第二个智能体会在你发布前审查每一处更改。" + } + } + } }, "Add a Git repository by URL" : { "extractionState" : "stale", @@ -1249,6 +1262,22 @@ } } }, + "Add Item" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加项" + } + } + } + }, "Add MCP Server" : { "localizations" : { "ko" : { @@ -1374,10 +1403,36 @@ } }, "Add project from remote repositories" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 저장소에서 프로젝트 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从远程仓库添加项目" + } + } + } }, "Add project locally" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬에서 프로젝트 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从本地添加项目" + } + } + } }, "Add Relay Server" : { "localizations" : { @@ -1561,6 +1616,22 @@ } } }, + "Add your own items to the project, thread, and briefing-card menus. They sync to mobile automatically." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트, 스레드, 브리핑 카드 메뉴에 직접 항목을 추가하세요. 모바일에 자동으로 동기화됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向项目、线程和简报卡片菜单中添加你自己的项。它们会自动同步到移动设备。" + } + } + } + }, "Added" : { "localizations" : { "en" : { @@ -1584,7 +1655,20 @@ } }, "After a successful session, the agent is asked to commit the changed files and push them (choosing a new or existing branch). The commit turn itself won't re-trigger this hook. If a Code Review hook is also enabled, the commit only happens after the review passes." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션이 성공적으로 끝나면 에이전트에게 변경된 파일을 커밋하고 푸시하도록 요청합니다(새 브랜치 또는 기존 브랜치 선택). 커밋 턴 자체는 이 훅을 다시 트리거하지 않습니다. 코드 리뷰 훅도 활성화된 경우 리뷰를 통과한 후에만 커밋이 이루어집니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话成功结束后,会要求智能体提交更改的文件并推送(选择新分支或现有分支)。提交这一轮本身不会再次触发此 Hook。如果同时启用了代码审查 Hook,则只有在审查通过后才会提交。" + } + } + } }, "After Session Stop" : { "extractionState" : "stale", @@ -1604,10 +1688,36 @@ } }, "After the session is finalized, the change is sent to a linked [Code Review] thread that runs no hooks. If the review requests changes, its notes are sent back into this thread so the agent keeps fixing and is re-reviewed (up to 3 times)." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션이 완료되면 변경 사항이 훅을 실행하지 않는 연결된 [Code Review] 스레드로 전송됩니다. 리뷰에서 변경을 요청하면 해당 노트가 이 스레드로 다시 전송되어 에이전트가 계속 수정하고 다시 검토받습니다(최대 3회)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话完成后,更改会发送到一个不运行任何 Hook 的关联 [Code Review] 线程。如果审查要求修改,其备注会发送回此线程,以便智能体持续修复并重新接受审查(最多 3 次)。" + } + } + } }, "After the session is finalized, your message is sent to the assistant — as a follow-up in this thread or into a new linked thread that runs no hooks. With a condition, a model first decides yes/no. Fires once per session and resets when you send a new message." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션이 완료되면 메시지가 어시스턴트로 전송됩니다 — 이 스레드의 후속 메시지로 또는 훅을 실행하지 않는 새 연결 스레드로. 조건이 있으면 모델이 먼저 예/아니요를 결정합니다. 세션당 한 번 실행되며 새 메시지를 보내면 초기화됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话完成后,你的消息会发送给助手——作为此线程中的后续消息,或发送到一个不运行任何 Hook 的新关联线程。设置条件后,模型会先判断是/否。每个会话触发一次,发送新消息时重置。" + } + } + } }, "Agent Availability" : { "localizations" : { @@ -1721,6 +1831,22 @@ } } }, + "All projects" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 프로젝트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有项目" + } + } + } + }, "All saved memories will be removed. This action cannot be undone." : { "localizations" : { "ko" : { @@ -1879,6 +2005,22 @@ } } }, + "An SF Symbol name shown beside the title, e.g. \"bolt\"." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 옆에 표시되는 SF Symbol 이름입니다. 예: \"bolt\"." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示在标题旁边的 SF Symbol 名称,例如 “bolt”。" + } + } + } + }, "API Key" : { "localizations" : { "ko" : { @@ -2402,7 +2544,20 @@ } }, "Automatic code review" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동 코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动代码审查" + } + } + } }, "Automation" : { "localizations" : { @@ -2620,6 +2775,22 @@ } } }, + "Body" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "본문" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正文" + } + } + } + }, "Branch" : { "localizations" : { "ko" : { @@ -2684,6 +2855,22 @@ } } }, + "Briefing card menu" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리핑 카드 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "简报卡片菜单" + } + } + } + }, "Briefing rolls recent thread summaries into a branch-focused project update." : { "localizations" : { "ko" : { @@ -2764,6 +2951,54 @@ } } }, + "Build your own project, thread, and briefing-card menu items in Settings → Context Menus. They sync to your phone automatically." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정 → 컨텍스트 메뉴에서 프로젝트, 스레드, 브리핑 카드 메뉴 항목을 직접 만드세요. 휴대폰에 자동으로 동기화됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在“设置 → 上下文菜单”中构建你自己的项目、线程和简报卡片菜单项。它们会自动同步到你的手机。" + } + } + } + }, + "Call an external API — define the method, URL, headers, and body once, with {{placeholders}} for project and branch context." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 API 호출 — 메서드, URL, 헤더, 본문을 한 번만 정의하고 프로젝트와 브랜치 컨텍스트에 {{placeholders}}를 사용하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调用外部 API——一次性定义方法、URL、请求头和正文,并使用 {{placeholders}} 表示项目和分支上下文。" + } + } + } + }, + "Call external API" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "외부 API 호출" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "调用外部 API" + } + } + } + }, "Cancel" : { "localizations" : { "en" : { @@ -3263,170 +3498,357 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭 PR" + "value" : "关闭 PR" + } + } + } + }, + "Close Terminal" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "터미널 닫기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭终端" + } + } + } + }, + "Code Review" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码审查" + } + } + } + }, + "Code Review for %@" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "审查 %@" + } + } + } + }, + "Code Review for Current Branch" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 브랜치 코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "审查当前分支" + } + } + } + }, + "Code Review for this thread" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 스레드 코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "审查此线程" + } + } + } + }, + "Code review found issues" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰에서 문제 발견" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码审查发现问题" + } + } + } + }, + "Code review in progress" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 진행 중" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码审查进行中" + } + } + } + }, + "Code review passed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 통과" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码审查通过" + } + } + } + }, + "Collapse chats" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅 접기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折叠聊天" + } + } + } + }, + "command" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "command" + } + } + } + }, + "Command" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Command" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커맨드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "命令" + } + } + } + }, + "Command Failed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령 실패" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "命令失败" + } + } + } + }, + "Commands" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "命令" + } + } + } + }, + "Commit & Push" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커밋 및 푸시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提交并推送" } } } }, - "Close Terminal" : { + "Commit %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "터미널 닫기" + "value" : "%@ 커밋" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭终端" + "value" : "提交 %@" } } } }, - "Code Review" : { - - }, - "Code Review for %@" : { - - }, - "Code Review for Current Branch" : { - - }, - "Code Review for this thread" : { - - }, - "Code review found issues" : { - - }, - "Code review in progress" : { - - }, - "Code review passed" : { - - }, - "Collapse chats" : { + "Commit All Changes" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "채팅 접기" + "value" : "모든 변경 사항 커밋" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "折叠聊天" + "value" : "提交所有更改" } } } }, - "command" : { + "Commit Files" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "명령" + "value" : "파일 커밋" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "command" + "value" : "提交文件" } } } }, - "Command" : { + "Commit message" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Command" - } - }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "커맨드" + "value" : "커밋 메시지" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "命令" + "value" : "提交消息" } } } }, - "Command Failed" : { - - }, - "Commands" : { + "Commit when a session finishes" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "명령" + "value" : "세션이 끝나면 커밋" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "命令" + "value" : "会话结束时提交" } } } }, - "Commit & Push" : { - - }, - "Commit %@" : { + "Condition" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@ 커밋" + "value" : "조건" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "提交 %@" + "value" : "条件" } } } }, - "Commit All Changes" : { - - }, - "Commit Files" : { - - }, - "Commit message" : { + "Condition model" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "커밋 메시지" + "value" : "조건 모델" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "提交消息" + "value" : "条件模型" } } } - }, - "Commit when a session finishes" : { - - }, - "Condition" : { - - }, - "Condition model" : { - }, "Configuration" : { "localizations" : { @@ -3751,6 +4173,22 @@ } } }, + "Context Menus" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "컨텍스트 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上下文菜单" + } + } + } + }, "Continue" : { "localizations" : { "ko" : { @@ -3767,6 +4205,38 @@ } } }, + "Continue existing thread" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기존 스레드 계속하기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续现有线程" + } + } + } + }, + "Continue thread" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 계속하기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续线程" + } + } + } + }, "Control what autopilot does automatically — issue labeling, PR validation and linking, project field population, and more." : { "localizations" : { "ko" : { @@ -3946,6 +4416,22 @@ } } }, + "Create a new thread or continue an existing one with a templated message." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "템플릿 메시지로 새 스레드를 만들거나 기존 스레드를 계속합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用模板消息创建新线程或继续现有线程。" + } + } + } + }, "Create and checkout" : { "localizations" : { "ko" : { @@ -4011,6 +4497,22 @@ } } }, + "Create new thread" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 스레드 만들기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建新线程" + } + } + } + }, "Create PR" : { "localizations" : { "ko" : { @@ -4028,6 +4530,7 @@ } }, "Create Pull Request" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -4253,6 +4756,38 @@ } } }, + "Custom context menus" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 컨텍스트 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义上下文菜单" + } + } + } + }, + "Custom Context Menus" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 컨텍스트 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义上下文菜单" + } + } + } + }, "Custom script" : { "localizations" : { "ko" : { @@ -5368,6 +5903,22 @@ } } }, + "Edit Menu Item" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메뉴 항목 편집" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑菜单项" + } + } + } + }, "Edit Relay Server" : { "localizations" : { "ko" : { @@ -5416,6 +5967,22 @@ } } }, + "Edit…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑…" + } + } + } + }, "Effort level: %@" : { "localizations" : { "ko" : { @@ -5487,7 +6054,20 @@ } }, "Empty Node.js Configuration" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비어 있는 Node.js 구성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空的 Node.js 配置" + } + } + } }, "Empty Package Configuration" : { "extractionState" : "stale", @@ -5910,7 +6490,20 @@ } }, "Extra instructions (optional)" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가 지침 (선택 사항)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "额外说明(可选)" + } + } + } }, "Fact" : { "localizations" : { @@ -5951,7 +6544,20 @@ } }, "Failed reviews are sent back to the original thread so the agent can fix and get re-reviewed." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실패한 리뷰는 원래 스레드로 다시 전송되어 에이전트가 수정하고 다시 검토받을 수 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未通过的审查会发送回原始线程,以便智能体修复并重新接受审查。" + } + } + } }, "Failed to check status" : { "localizations" : { @@ -6116,6 +6722,7 @@ } }, "Fix Failing CI" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6506,64 +7113,90 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "全局" + "value" : "全局" + } + } + } + }, + "Global default" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局默认" + } + } + } + }, + "Global default plus per-project override" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전역 기본값 및 프로젝트별 재정의" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全局默认值加项目级覆盖" } } } }, - "Global default" : { + "Got it" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "전역 기본값" + "value" : "확인" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "全局默认" + "value" : "知道了" } } } }, - "Global default plus per-project override" : { + "Headers" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "전역 기본값 및 프로젝트별 재정의" + "value" : "헤더" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "全局默认值加项目级覆盖" + "value" : "标头" } } } }, - "Got it" : { - - }, - "Headers" : { + "Hide code reviews" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "헤더" + "value" : "코드 리뷰 숨기기" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标头" + "value" : "隐藏代码审查" } } } - }, - "Hide code reviews" : { - }, "Hide details" : { "localizations" : { @@ -7425,7 +8058,20 @@ } }, "Key" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "키" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键" + } + } + } }, "KEY" : { "localizations" : { @@ -8332,6 +8978,22 @@ } } }, + "Method" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메서드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "方法" + } + } + } + }, "Mobile" : { "localizations" : { "ko" : { @@ -8641,7 +9303,36 @@ } }, "New in RxCode" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode의 새로운 기능" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 新功能" + } + } + } + }, + "New Menu Item" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 메뉴 항목" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建菜单项" + } + } + } }, "New Template" : { "localizations" : { @@ -8692,10 +9383,36 @@ } }, "New thread" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 스레드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建线程" + } + } + } }, "New thread label" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 스레드 레이블" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新线程标签" + } + } + } }, "New thread starts" : { "extractionState" : "stale", @@ -8902,6 +9619,22 @@ } } }, + "No custom items yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 사용자 지정 항목이 없습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无自定义项。" + } + } + } + }, "No custom repositories" : { "extractionState" : "stale", "localizations" : { @@ -9575,7 +10308,20 @@ } }, "Node.js" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js" + } + } + } }, "Non-zero → agent continues" : { "extractionState" : "stale", @@ -9843,7 +10589,20 @@ } }, "Only send when a condition is met" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "조건이 충족될 때만 전송" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅在满足条件时发送" + } + } + } }, "Open" : { "localizations" : { @@ -10594,7 +11353,20 @@ } }, "Pick which model performs the review — defaults to the same model as the thread." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리뷰를 수행할 모델을 선택하세요 — 기본값은 스레드와 동일한 모델입니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择执行审查的模型——默认与线程使用相同的模型。" + } + } + } }, "Pin" : { "localizations" : { @@ -10822,6 +11594,22 @@ } } }, + "Project menu" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로젝트 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "项目菜单" + } + } + } + }, "Project name" : { "localizations" : { "en" : { @@ -10996,7 +11784,20 @@ } }, "Push to the remote automatically, or keep the commit local." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격에 자동으로 푸시하거나 커밋을 로컬에 유지합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动推送到远程,或将提交保留在本地。" + } + } + } }, "Quit" : { "localizations" : { @@ -11506,6 +12307,22 @@ } } }, + "Remove menu item?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메뉴 항목을 제거하시겠습니까?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除菜单项?" + } + } + } + }, "Remove Release Repository" : { "localizations" : { "ko" : { @@ -11764,6 +12581,22 @@ } } }, + "Request" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요청" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求" + } + } + } + }, "Reset" : { "localizations" : { "en" : { @@ -11988,7 +12821,20 @@ } }, "Review model" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리뷰 모델" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "审查模型" + } + } + } }, "Review the CLI setup check" : { "localizations" : { @@ -12135,7 +12981,20 @@ } }, "Runs after a session finishes and reviews the modified files in a linked thread." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션이 끝난 후 실행되어 연결된 스레드에서 수정된 파일을 검토합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在会话结束后运行,并在关联线程中审查已修改的文件。" + } + } + } }, "Runs after streaming stops. Its output is shown only — nothing is passed back to the session." : { "extractionState" : "stale", @@ -12335,10 +13194,36 @@ } }, "Same as thread" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드와 동일" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与线程相同" + } + } + } }, "Same thread" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동일한 스레드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "同一线程" + } + } + } }, "Save" : { "localizations" : { @@ -12532,6 +13417,22 @@ } } }, + "Scope an item to one project or all of them; it shows up on desktop and mobile alike." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 하나의 프로젝트 또는 모든 프로젝트로 범위를 지정하세요. 데스크톱과 모바일 모두에 표시됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将某项限定到单个项目或所有项目;它会同时显示在桌面端和移动端。" + } + } + } + }, "Script" : { "localizations" : { "ko" : { @@ -12848,7 +13749,20 @@ } }, "See the latest features and updates" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최신 기능 및 업데이트 보기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看最新功能和更新" + } + } + } }, "Select a dispatchable release workflow first (re-add the repo to rescan if needed)." : { "localizations" : { @@ -13115,7 +14029,20 @@ } }, "Send Message" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메시지 보내기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送消息" + } + } + } }, "Send test notification" : { "localizations" : { @@ -13134,7 +14061,20 @@ } }, "Send to" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보낼 대상" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送至" + } + } + } }, "Sends a system notification while RxCode is in the background." : { "localizations" : { @@ -13229,9 +14169,24 @@ } }, "Set Up CI Update" : { - + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CI 업데이트 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置 CI 更新" + } + } + } }, "Set Up Docs" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -13297,6 +14252,7 @@ } }, "Set Up Release Workflow" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -13329,6 +14285,7 @@ } }, "Set Up Secrets" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -13392,6 +14349,22 @@ } } }, + "SF Symbol" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol" + } + } + } + }, "Shortcuts" : { "localizations" : { "en" : { @@ -13415,7 +14388,20 @@ } }, "Show %lld code review(s)" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 %lld개 표시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示 %lld 个代码审查" + } + } + } }, "Show active chats" : { "localizations" : { @@ -13541,6 +14527,22 @@ } } }, + "Show in" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "표시 위치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示于" + } + } + } + }, "Show in Finder" : { "localizations" : { "en" : { @@ -14113,7 +15115,21 @@ } }, "Stop Code Review" : { - + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 중지" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停止代码审查" + } + } + } }, "Stop running tasks" : { "localizations" : { @@ -14213,7 +15229,20 @@ } }, "Summarization model" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "요약 모델" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "摘要模型" + } + } + } }, "Summarization Model" : { "localizations" : { @@ -14318,6 +15347,22 @@ } } }, + "Target thread id" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상 스레드 ID" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标线程 ID" + } + } + } + }, "Templates of GitHub merge settings and an optional branch ruleset. Your default template is applied automatically to new repositories." : { "localizations" : { "ko" : { @@ -14399,7 +15444,20 @@ } }, "The new Commit & Push hook commits — and optionally pushes — your work the moment an agent session completes." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로운 커밋 및 푸시 훅은 에이전트 세션이 완료되는 즉시 작업을 커밋하고 선택적으로 푸시합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全新的“提交并推送” Hook 会在智能体会话完成的那一刻提交你的工作——并可选择推送。" + } + } + } }, "The next version is computed by semantic-release from the commit history." : { "localizations" : { @@ -14433,6 +15491,22 @@ } } }, + "The session id of the thread to continue. Leave a {{sessionId}} placeholder to target the tapped thread." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계속할 스레드의 세션 ID입니다. 탭한 스레드를 대상으로 하려면 {{sessionId}} 플레이스홀더를 남겨두세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "要继续的线程的会话 ID。保留 {{sessionId}} 占位符可指向所点按的线程。" + } + } + } + }, "Theme" : { "localizations" : { "en" : { @@ -14669,8 +15743,37 @@ } } }, + "Thread menu" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 메뉴" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "线程菜单" + } + } + } + }, "Thread model" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 모델" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "线程模型" + } + } + } }, "Thread Model" : { "localizations" : { @@ -14753,6 +15856,22 @@ } } }, + "Title" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标题" + } + } + } + }, "Todos (%lld/%lld)" : { "localizations" : { "en" : { @@ -14840,7 +15959,20 @@ } }, "Triggers automatically once an agent session finishes." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에이전트 세션이 끝나면 자동으로 트리거됩니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "智能体会话结束后自动触发。" + } + } + } }, "Type" : { "localizations" : { @@ -14999,6 +16131,22 @@ } } }, + "URLs, bodies, and messages can use placeholders: {{projectName}}, {{projectPath}}, {{gitHubRepo}}, {{branch}}, {{sessionId}}." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL, 본문, 메시지에는 플레이스홀더를 사용할 수 있습니다: {{projectName}}, {{projectPath}}, {{gitHubRepo}}, {{branch}}, {{sessionId}}." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL、正文和消息可以使用占位符:{{projectName}}、{{projectPath}}、{{gitHubRepo}}、{{branch}}、{{sessionId}}。" + } + } + } + }, "Use a GitHub repository that exposes .claude-plugin/marketplace.json." : { "localizations" : { "ko" : { @@ -15463,10 +16611,36 @@ } }, "What's New" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로운 기능" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新功能" + } + } + } }, "When a Code Review hook is configured, commits only happen after the review passes." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 훅이 구성된 경우 리뷰를 통과한 후에만 커밋이 이루어집니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置代码审查 Hook 后,只有在审查通过后才会提交。" + } + } + } }, "When CI fails on a project's current branch, automatically start a thread so an agent can fix it. CI failures are always notified; this only controls the automatic fix." : { "localizations" : { @@ -15484,6 +16658,22 @@ } } }, + "When tapped" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭했을 때" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点按时" + } + } + } + }, "Will overwrite the existing %@." : { "localizations" : { "ko" : { @@ -15695,4 +16885,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 94a48fb4..aae3aa69 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -36,6 +36,18 @@ final class AppStateHookController: HookController { isStreaming: false )) } + // Persist the card immediately (in-progress) so it survives an app reload + // even before it completes — hook cards never reach the CLI transcript, + // so this sidecar row is the only copy. `completeCard` updates it. + app?.threadStore.upsertHookCard( + sessionId: sessionKey, + toolId: toolId, + toolName: toolName, + input: input, + result: nil, + isError: false, + isComplete: false + ) return HookCardHandle(toolId: toolId, messageId: messageId) } @@ -52,18 +64,32 @@ final class AppStateHookController: HookController { state.messages[idx].isStreaming = false state.messages[idx].isResponseComplete = true } + // Persist completion so the finished card (result + badge) survives a + // reload, matching what's now shown live. + app?.threadStore.completeHookCard(toolId: handle.toolId, result: result, isError: isError) } func persistHookStatus(sessionKey: String, toolId: String, name: String, trigger: String, output: String, isError: Bool, isComplete: Bool) { - app?.threadStore.setHookStatus( - sessionId: sessionKey, - toolId: toolId, - name: name, - trigger: trigger, - output: output, - isError: isError, - isComplete: isComplete - ) + guard let store = app?.threadStore else { return } + // The card was already persisted in full by `insertCard`; only update its + // status fields so the original `toolName`/`input` (e.g. a code-review + // card's `summary`) is preserved on disk. Fall back to an upsert with the + // reduced legacy payload only if the card was never inserted through this + // controller. + if !store.completeHookCard(toolId: toolId, result: output, isError: isError, isComplete: isComplete) { + store.upsertHookCard( + sessionId: sessionKey, + toolId: toolId, + toolName: AppState.hookToolName(for: name), + input: [ + "name": .string(name), + "trigger": .string(trigger), + ], + result: output, + isError: isError, + isComplete: isComplete + ) + } } func enabledHookProfiles(projectId: UUID, trigger: HookTrigger) async -> [HookProfile] { @@ -376,6 +402,15 @@ final class AppStateHookController: HookController { } } + func lastReviewFeedback(sessionId: String) -> String? { + app?.lastReviewFeedbackBySession[sessionId] + } + + func setLastReviewFeedback(_ feedback: String?, sessionId: String) { + let trimmed = feedback?.trimmingCharacters(in: .whitespacesAndNewlines) + app?.lastReviewFeedbackBySession[sessionId] = (trimmed?.isEmpty == false) ? trimmed : nil + } + func repromptThreadAfterReviewFailure(feedback: String, project: Project, sessionKey: String) -> Int? { guard let app else { return nil } return app.repromptAfterReviewFailure(feedback: feedback, project: project, sessionKey: sessionKey) @@ -697,6 +732,10 @@ final class AppStateHookController: HookController { app?.threadHasFileChanges(sessionId: sessionId) ?? false } + func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord] { + app?.threadStore.customMenuItems(projectId: projectId, surface: surface) ?? [] + } + func requestSecretsSetup(project: Project) { app?.secretsSetupRequest = SecretsSetupRequest( repoFullName: project.gitHubRepo, diff --git a/RxCode/Services/Hooks/MenuLocalizer.swift b/RxCode/Services/Hooks/MenuLocalizer.swift new file mode 100644 index 00000000..cf758535 --- /dev/null +++ b/RxCode/Services/Hooks/MenuLocalizer.swift @@ -0,0 +1,53 @@ +#if os(macOS) +import Foundation + +/// Resolves built-in menu item titles in an arbitrary locale. +/// +/// The desktop is the only side that builds menus, so titles are localized here. +/// For its own native menus we use the desktop's system locale (`Bundle.main`); +/// when mobile requests a menu it passes its own locale, and we look the title up +/// in that language's `.lproj` bundle so the phone shows translated titles even +/// though the Mac runs a different language. +enum MenuLocalizer { + /// Localized string for `key` in `locale` (nil => desktop system locale). + /// `key` is the English source string (the string-catalog key). + static func string(_ key: String, locale: String?) -> String { + bundle(for: locale).localizedString(forKey: key, value: key, table: nil) + } + + /// Localized format string for `key`, filled with `args`. Use for titles with + /// interpolation, e.g. key `"Code Review for %@"`. + static func format(_ key: String, locale: String?, _ args: CVarArg...) -> String { + let fmt = bundle(for: locale).localizedString(forKey: key, value: key, table: nil) + return String(format: fmt, locale: Locale(identifier: locale ?? Locale.current.identifier), arguments: args) + } + + /// The `.lproj` bundle for `locale`, falling back to the main bundle (system + /// locale) when no specific match exists. + private static func bundle(for locale: String?) -> Bundle { + guard let locale, !locale.isEmpty else { return .main } + for code in candidateCodes(locale) { + if let path = Bundle.main.path(forResource: code, ofType: "lproj"), + let bundle = Bundle(path: path) { + return bundle + } + } + return .main + } + + /// Progressive language candidates, most specific first. Handles `_`/`-` + /// separators: "zh-Hans-CN" -> ["zh-Hans-CN", "zh-Hans", "zh"]. + private static func candidateCodes(_ locale: String) -> [String] { + let normalized = locale.replacingOccurrences(of: "_", with: "-") + let parts = normalized.split(separator: "-").map(String.init) + guard !parts.isEmpty else { return [normalized] } + var result: [String] = [] + var current = parts + while !current.isEmpty { + result.append(current.joined(separator: "-")) + current.removeLast() + } + return result + } +} +#endif diff --git a/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift b/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift index 7cbd2e88..fce7e089 100644 --- a/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift +++ b/RxCode/Services/Hooks/hooks/ActionsMenuHook.swift @@ -20,10 +20,10 @@ final class ActionsMenuHook: Hook { // MARK: - Project menu func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { - projectItems(for: payload.project, branch: payload.branch, controller: controller) + projectItems(for: payload.project, branch: payload.branch, locale: payload.locale, controller: controller) } - private func projectItems(for project: Project, branch: String?, controller: any HookController) -> [MenuItem] { + private func projectItems(for project: Project, branch: String?, locale: String?, controller: any HookController) -> [MenuItem] { var items: [MenuItem] = [] // A branch-scoped menu (e.g. a briefing card) addresses `branch`; a // generic project menu addresses the current branch (carried as nil so @@ -33,8 +33,8 @@ final class ActionsMenuHook: Hook { // Code review for the branch (or the current branch when unscoped). items.append(MenuItem( id: "\(hookID).project.codeReview.\(project.id.uuidString)\(branchSuffix)", - title: branch.map { String(localized: "Code Review for \($0)") } - ?? String(localized: "Code Review for Current Branch"), + title: branch.map { MenuLocalizer.format("Code Review for %@", locale: locale, $0) } + ?? MenuLocalizer.string("Code Review for Current Branch", locale: locale), systemImage: "checklist", action: .command(MenuActionCommand(kind: .projectCodeReview, projectId: project.id, branch: branch)) )) @@ -43,7 +43,7 @@ final class ActionsMenuHook: Hook { if controller.projectHasUncommittedChanges(project) { items.append(MenuItem( id: "\(hookID).project.commitAll.\(project.id.uuidString)", - title: String(localized: "Commit All Changes"), + title: MenuLocalizer.string("Commit All Changes", locale: locale), systemImage: "checkmark.circle", action: .command(MenuActionCommand(kind: .projectCommitAll, projectId: project.id)) )) @@ -55,7 +55,7 @@ final class ActionsMenuHook: Hook { if project.gitHubRepo != nil, !controller.projectHasOpenPullRequest(project, branch: branch) { items.append(MenuItem( id: "\(hookID).project.createPR.\(project.id.uuidString)\(branchSuffix)", - title: String(localized: "Create Pull Request"), + title: MenuLocalizer.string("Create Pull Request", locale: locale), systemImage: "arrow.triangle.pull", action: .command(MenuActionCommand(kind: .projectCreatePullRequest, projectId: project.id, branch: branch, isAsync: true)) )) @@ -66,7 +66,7 @@ final class ActionsMenuHook: Hook { if controller.projectCIIsFailing(project, branch: branch) { items.append(MenuItem( id: "\(hookID).project.fixCI.\(project.id.uuidString)\(branchSuffix)", - title: String(localized: "Fix Failing CI"), + title: MenuLocalizer.string("Fix Failing CI", locale: locale), systemImage: "wrench.and.screwdriver", action: .command(MenuActionCommand(kind: .projectFixCI, projectId: project.id, branch: branch)) )) @@ -81,6 +81,7 @@ final class ActionsMenuHook: Hook { // A `[Code Review]` thread doesn't review or commit itself. if payload.session.threadLabel == AppState.manualCodeReviewLabel { return [] } + let locale = payload.locale var items: [MenuItem] = [] // Stop an in-flight automatic review (countdown or running review thread). @@ -88,7 +89,7 @@ final class ActionsMenuHook: Hook { if controller.threadHasOngoingReview(sessionId: payload.session.id) { items.append(MenuItem( id: "\(hookID).thread.stopCodeReview.\(payload.session.id)", - title: String(localized: "Stop Code Review"), + title: MenuLocalizer.string("Stop Code Review", locale: locale), systemImage: "stop.circle", role: .destructive, action: .command(MenuActionCommand(kind: .threadStopCodeReview, sessionId: payload.session.id)) @@ -97,7 +98,7 @@ final class ActionsMenuHook: Hook { items.append(MenuItem( id: "\(hookID).thread.codeReview.\(payload.session.id)", - title: String(localized: "Code Review for this thread"), + title: MenuLocalizer.string("Code Review for this thread", locale: locale), systemImage: "checklist", action: .command(MenuActionCommand(kind: .threadCodeReview, sessionId: payload.session.id)) )) @@ -106,7 +107,7 @@ final class ActionsMenuHook: Hook { if controller.threadHasFileChanges(sessionId: payload.session.id) { items.append(MenuItem( id: "\(hookID).thread.commitFiles.\(payload.session.id)", - title: String(localized: "Commit Files"), + title: MenuLocalizer.string("Commit Files", locale: locale), systemImage: "checkmark.circle", action: .command(MenuActionCommand(kind: .threadCommitFiles, sessionId: payload.session.id)) )) diff --git a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift index e7d718a7..f62c0b09 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -22,14 +22,14 @@ final class AutopilotDocsHook: Hook { } func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { + private func menuItems(for project: Project, locale: String?, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } // Docs search was removed from the menu. Once a repo's docs are indexed // there's nothing to do here; otherwise offer to set them up. @@ -37,7 +37,7 @@ final class AutopilotDocsHook: Hook { return [ MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: String(localized: "Set Up Docs"), + title: MenuLocalizer.string("Set Up Docs", locale: locale), systemImage: "books.vertical.fill", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.docsSetup, projectId: project.id)) ) diff --git a/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift index c8073e71..f8d95352 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift @@ -22,20 +22,20 @@ final class AutopilotReleaseHook: Hook { } func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { + private func menuItems(for project: Project, locale: String?, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } if controller.projectHasReleaseWorkflow(project) { return [ MenuItem( id: "\(hookID).create.\(project.id.uuidString)", - title: String(localized: "Create Release"), + title: MenuLocalizer.string("Create Release", locale: locale), systemImage: "tag.fill", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.releaseCreate, projectId: project.id)) ) @@ -45,7 +45,7 @@ final class AutopilotReleaseHook: Hook { return [ MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: String(localized: "Set Up Release Workflow"), + title: MenuLocalizer.string("Set Up Release Workflow", locale: locale), systemImage: "tag.fill", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.releaseSetup, projectId: project.id)) ) diff --git a/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift index 924289c2..be7f8b60 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift @@ -22,20 +22,20 @@ final class AutopilotSecretsHook: Hook { } func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } - private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { + private func menuItems(for project: Project, locale: String?, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } if controller.projectHasSecrets(project) { return [ MenuItem( id: "\(hookID).download.\(project.id.uuidString)", - title: String(localized: "Download Secret"), + title: MenuLocalizer.string("Download Secret", locale: locale), systemImage: "key.fill", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.secretsDownload, projectId: project.id)) ) @@ -45,7 +45,7 @@ final class AutopilotSecretsHook: Hook { return [ MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: String(localized: "Set Up Secrets"), + title: MenuLocalizer.string("Set Up Secrets", locale: locale), systemImage: "key.fill", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.secretsSetup, projectId: project.id)) ) diff --git a/RxCode/Services/Hooks/hooks/CIUpdateHook.swift b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift index 8e744da2..5ea1d678 100644 --- a/RxCode/Services/Hooks/hooks/CIUpdateHook.swift +++ b/RxCode/Services/Hooks/hooks/CIUpdateHook.swift @@ -27,11 +27,11 @@ final class CIUpdateHook: Hook { // MARK: Context menu func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { - menuItems(for: payload.project, controller: controller) + menuItems(for: payload.project, locale: payload.locale, controller: controller) } /// Offers "Set Up CI Update" when the project has a linked GitHub repo that @@ -40,14 +40,14 @@ final class CIUpdateHook: Hook { /// alongside secrets/docs/release), so an already-set-up repo no longer shows /// the item. It's a `.deepLink` (presents the CI setup sheet locally) rather /// than a `.command`, so desktop and mobile each open their own UI. - private func menuItems(for project: Project, controller: any HookController) -> [MenuItem] { + private func menuItems(for project: Project, locale: String?, controller: any HookController) -> [MenuItem] { guard project.gitHubRepo != nil else { return [] } // Already a watched repo → CI auto-update is set up; nothing to offer. guard !controller.projectHasCIUpdates(project) else { return [] } return [ MenuItem( id: "\(hookID).setup.\(project.id.uuidString)", - title: String(localized: "Set Up CI Update"), + title: MenuLocalizer.string("Set Up CI Update", locale: locale), systemImage: "arrow.triangle.2.circlepath", action: .deepLink(MenuDeepLink.url(action: MenuDeepLink.ciSetup, projectId: project.id)) ) diff --git a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift index 24733f1c..c5549740 100644 --- a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift +++ b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift @@ -114,10 +114,16 @@ final class CodeReviewHook: Hook { storedModel: hook.codeReview?.model, fallbackSessionId: payload.sessionId ) + // If a prior review of this thread failed and the agent has since pushed + // a fix turn, hand the reviewer that earlier feedback so it can verify + // those specific findings were addressed instead of re-deriving the whole + // review from scratch — a faster, more focused re-review. + let priorReview = controller.lastReviewFeedback(sessionId: payload.sessionKey) let prompt = reviewPrompt( task: task, changedFiles: changedFiles, finalResponse: payload.lastAssistantText, + priorReview: priorReview, extraInstructions: hook.codeReview?.instructions ) @@ -197,8 +203,10 @@ final class CodeReviewHook: Hook { result: "✅ Code review passed.\n\(reviewLink)\n\n\(body)", isError: false) recordVerdict(true, payload: payload, controller: controller) // The change is good — clear the fix-round counter so the next change - // on this thread gets the full auto-fix budget again. + // on this thread gets the full auto-fix budget again, and drop the + // carried-over feedback so a later review starts fresh. controller.setReviewRound(0, sessionId: payload.sessionKey) + controller.setLastReviewFeedback(nil, sessionId: payload.sessionKey) return .proceed case .fail: @@ -209,6 +217,9 @@ final class CodeReviewHook: Hook { // When that fix turn finishes, this hook runs again on the fixed // change (fix → review → fix), capped by `maxReviewFixReprompts`. recordVerdict(false, payload: payload, controller: controller) + // Remember this feedback so the re-review after the auto-fix turn can + // verify these findings were addressed rather than starting over. + controller.setLastReviewFeedback(result.assistantText, sessionId: payload.sessionKey) let attempt = controller.repromptThreadAfterReviewFailure( feedback: result.assistantText, project: payload.project, @@ -303,7 +314,7 @@ final class CodeReviewHook: Hook { return .unknown(notes: text) } - private func reviewPrompt(task: String, changedFiles: [String], finalResponse: String, extraInstructions: String?) -> String { + private func reviewPrompt(task: String, changedFiles: [String], finalResponse: String, priorReview: String?, extraInstructions: String?) -> String { let fileList = changedFiles.map { "- \($0)" }.joined(separator: "\n") var prompt = """ You are reviewing another agent's code change in this repository. Do not edit any files — only review. @@ -316,6 +327,26 @@ final class CodeReviewHook: Hook { ## The agent's final response \(finalResponse) + """ + + // Re-review: the previous round failed and the agent has since pushed a + // fix. Anchor the reviewer on those findings so it can confirm each was + // resolved instead of re-deriving the whole review — faster and focused. + if let prior = priorReview?.trimmingCharacters(in: .whitespacesAndNewlines), !prior.isEmpty { + prompt += """ + + + ## Your previous review of this change (it then FAILED) + The agent has just pushed a fix turn addressing this feedback. Focus your + re-review on whether each issue below is now resolved; only flag new + problems if the fix introduced them. + + \(prior) + """ + } + + prompt += """ + ## What to do Inspect the changed files and judge whether the change correctly and safely accomplishes the task. Look for bugs, missed requirements, regressions, and obvious quality problems. diff --git a/RxCode/Services/Hooks/hooks/CustomMenuHook.swift b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift new file mode 100644 index 00000000..0bb29f55 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/CustomMenuHook.swift @@ -0,0 +1,125 @@ +#if os(macOS) +import Foundation +import RxCodeCore + +/// Surfaces the user's own context-menu entries (configured in Settings → +/// Context Menus) on the project, thread, and briefing-card menus. Each enabled +/// `CustomMenuItemRecord` becomes a serializable `MenuItem` carrying a +/// `.custom` command — so the desktop renders it natively and mobile fetches the +/// identical item over the relay. The work itself runs on the desktop through +/// `AppState.dispatchMenuCommand`. +/// +/// Context placeholders in the request/message templates (`{{projectName}}`, +/// `{{projectPath}}`, `{{gitHubRepo}}`, `{{branch}}`, `{{sessionId}}`) are +/// substituted here at build time, where the project/branch/thread are known, so +/// the command that crosses the relay is fully resolved. +@MainActor +final class CustomMenuHook: Hook { + let hookID = "builtin.customMenu" + + // MARK: - Project / briefing menus + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [MenuItem] { + // A branch-scoped menu is a briefing card; an unscoped one is the generic + // project menu. Each surfaces its own configured items. + let surface: CustomMenuItemRecord.Surface = payload.branch != nil ? .briefing : .project + let context = PlaceholderContext(project: payload.project, branch: payload.branch, sessionId: nil) + return controller.customMenuItems(projectId: payload.project.id, surface: surface) + .map { item($0, context: context, projectId: payload.project.id) } + } + + // MARK: - Thread menu + + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [MenuItem] { + let context = PlaceholderContext(project: payload.project, branch: nil, sessionId: payload.session.id) + return controller.customMenuItems(projectId: payload.project.id, surface: .thread) + .map { item($0, context: context, projectId: payload.project.id, sessionId: payload.session.id) } + } + + // MARK: - Build one item + + private func item( + _ record: CustomMenuItemRecord, + context: PlaceholderContext, + projectId: UUID, + sessionId: String? = nil + ) -> MenuItem { + let config = resolvedConfig(record, context: context, sessionId: sessionId) + let isAPI = record.actionKindValue == .callAPI + return MenuItem( + id: "\(hookID).\(record.surface).\(record.id)", + title: record.title, + systemImage: record.systemImage, + action: .command(MenuActionCommand( + kind: .custom, + projectId: projectId, + sessionId: sessionId, + branch: context.branch, + isAsync: isAPI, + custom: config + )) + ) + } + + /// Resolve a record's templated fields into a concrete `CustomMenuActionConfig`. + private func resolvedConfig( + _ record: CustomMenuItemRecord, + context: PlaceholderContext, + sessionId: String? + ) -> CustomMenuActionConfig { + switch record.actionKindValue { + case .callAPI: + let headers = record.headers.reduce(into: [String: String]()) { acc, pair in + acc[pair.key] = context.substitute(pair.value) + } + return CustomMenuActionConfig( + kind: .callAPI, + httpMethod: record.httpMethod ?? "GET", + url: context.substitute(record.urlString), + headers: headers.isEmpty ? nil : headers, + body: context.substitute(record.bodyTemplate) + ) + case .createThread: + return CustomMenuActionConfig( + kind: .createThread, + message: context.substitute(record.messageTemplate) + ) + case .continueThread: + // An explicit target wins (with placeholders like {{sessionId}} + // resolved); otherwise fall back to the tapped thread. + let resolvedTarget = context.substitute(record.targetSessionId)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return CustomMenuActionConfig( + kind: .continueThread, + message: context.substitute(record.messageTemplate), + targetSessionId: (resolvedTarget?.isEmpty == false) ? resolvedTarget : sessionId + ) + } + } +} + +/// The values available for `{{…}}` placeholder substitution in custom menu +/// templates, captured from the menu's context at build time. +private struct PlaceholderContext { + let project: Project + let branch: String? + let sessionId: String? + + /// Replace every known token in `template`. Returns nil for a nil input. + func substitute(_ template: String?) -> String? { + guard let template, !template.isEmpty else { return template } + var out = template + let replacements: [String: String] = [ + "{{projectName}}": project.name, + "{{projectPath}}": project.path, + "{{gitHubRepo}}": project.gitHubRepo ?? "", + "{{branch}}": branch ?? "", + "{{sessionId}}": sessionId ?? "" + ] + for (token, value) in replacements { + out = out.replacingOccurrences(of: token, with: value) + } + return out + } +} +#endif diff --git a/RxCode/Services/ThreadStore+CustomMenus.swift b/RxCode/Services/ThreadStore+CustomMenus.swift new file mode 100644 index 00000000..357ff23f --- /dev/null +++ b/RxCode/Services/ThreadStore+CustomMenus.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData +import RxCodeCore + +/// Persistence for user-defined custom context-menu items. The desktop is the +/// single source of truth; `CustomMenuHook` reads these to build menu items and +/// the Settings tab mutates them. +extension ThreadStore { + /// All custom menu items, ordered for display (sortOrder, then creation). + func allCustomMenuItems() -> [CustomMenuItemRecord] { + let descriptor = FetchDescriptor( + sortBy: [ + SortDescriptor(\.sortOrder, order: .forward), + SortDescriptor(\.createdAt, order: .forward) + ] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// Enabled items that should appear on `surface` for `projectId` — i.e. items + /// scoped to that project plus the "all projects" (nil) items. + func customMenuItems(projectId: UUID?, surface: CustomMenuItemRecord.Surface) -> [CustomMenuItemRecord] { + let raw = surface.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.isEnabled && $0.surface == raw }, + sortBy: [ + SortDescriptor(\.sortOrder, order: .forward), + SortDescriptor(\.createdAt, order: .forward) + ] + ) + let rows = (try? context.fetch(descriptor)) ?? [] + return rows.filter { $0.projectId == nil || $0.projectId == projectId } + } + + func fetchCustomMenuItem(id: String) -> CustomMenuItemRecord? { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + return (try? context.fetch(descriptor))?.first + } + + /// Insert a new record or update the existing one in place. + func upsertCustomMenuItem(_ record: CustomMenuItemRecord) { + if let existing = fetchCustomMenuItem(id: record.id) { + existing.title = record.title + existing.systemImage = record.systemImage + existing.projectId = record.projectId + existing.surface = record.surface + existing.actionKind = record.actionKind + existing.httpMethod = record.httpMethod + existing.urlString = record.urlString + existing.headersJSON = record.headersJSON + existing.bodyTemplate = record.bodyTemplate + existing.messageTemplate = record.messageTemplate + existing.targetSessionId = record.targetSessionId + existing.isEnabled = record.isEnabled + existing.sortOrder = record.sortOrder + existing.updatedAt = .now + } else { + context.insert(record) + } + save() + } + + func deleteCustomMenuItem(id: String) { + guard let row = fetchCustomMenuItem(id: id) else { return } + context.delete(row) + save() + } +} diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index f0fc6cd9..20ec56f9 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -14,10 +14,10 @@ final class ThreadStore { self.context = context } - /// Convenience initializer creating its own `ModelContainer` rooted at the - /// app's Application Support directory. - static func make() -> ThreadStore { - let schema = Schema([ + /// The full SwiftData schema for the thread store, shared by the file-backed + /// (`make`) and in-memory (`inMemory`, used by tests) factories. + static var schema: Schema { + Schema([ ChatThread.self, TodoSnapshot.self, ThreadFileEdit.self, @@ -27,8 +27,23 @@ final class ThreadStore { BranchBriefingRecord.self, ThreadEmbeddingChunk.self, MemoryRecord.self, - HookStatusRecord.self + HookStatusRecord.self, + HookCardRecord.self, + CustomMenuItemRecord.self ]) + } + + /// In-memory store over the full schema, for tests. + static func inMemory() -> ThreadStore { + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: schema, configurations: [config]) + return ThreadStore(context: ModelContext(container)) + } + + /// Convenience initializer creating its own `ModelContainer` rooted at the + /// app's Application Support directory. + static func make() -> ThreadStore { + let schema = Self.schema let url = Self.storeURL() let config = ModelConfiguration(schema: schema, url: url) do { @@ -37,6 +52,7 @@ final class ThreadStore { // Sweep hook cards left mid-run by a previous launch so they don't // rebuild as a perpetual spinner. store.finalizeInterruptedHooks() + store.finalizeInterruptedHookCards() return store } catch { // Fall back to an in-memory container so the app still launches. @@ -164,6 +180,7 @@ final class ThreadStore { deleteQueueRows(sessionKey: id) deletePlanDecisionRows(sessionId: id) deleteHookStatusRow(sessionId: id) + deleteHookCardRows(sessionId: id) deleteThreadSummaryRow(sessionId: id) deleteEmbeddingChunkRows(threadId: id) } @@ -347,6 +364,7 @@ final class ThreadStore { renameQueueKey(from: oldId, to: newId) renamePlanDecisions(from: oldId, to: newId) renameHookStatus(from: oldId, to: newId) + renameHookCards(from: oldId, to: newId) renameThreadSummary(from: oldId, to: newId) save() } @@ -401,6 +419,7 @@ final class ThreadStore { deleteQueueRows(sessionKey: id) deletePlanDecisionRows(sessionId: id) deleteHookStatusRow(sessionId: id) + deleteHookCardRows(sessionId: id) deleteThreadSummaryRow(sessionId: id) deleteEmbeddingChunkRows(threadId: id) save() @@ -423,6 +442,7 @@ final class ThreadStore { deleteQueueRows(sessionKey: id) deletePlanDecisionRows(sessionId: id) deleteHookStatusRow(sessionId: id) + deleteHookCardRows(sessionId: id) deleteThreadSummaryRow(sessionId: id) deleteEmbeddingChunkRows(threadId: id) } @@ -437,6 +457,8 @@ final class ThreadStore { for row in allPlans { context.delete(row) } let allHookStatuses = (try? context.fetch(FetchDescriptor())) ?? [] for row in allHookStatuses { context.delete(row) } + let allHookCards = (try? context.fetch(FetchDescriptor())) ?? [] + for row in allHookCards { context.delete(row) } let allSummaries = (try? context.fetch(FetchDescriptor())) ?? [] for row in allSummaries { context.delete(row) } let allBriefings = (try? context.fetch(FetchDescriptor())) ?? [] @@ -583,45 +605,54 @@ final class ThreadStore { for row in planDecisions(sessionId: sessionId) { context.delete(row) } } - // MARK: - Hook Status + // MARK: - Hook Cards - func loadHookStatus(sessionId: String) -> HookStatusRecord? { - fetchHookStatus(sessionId: sessionId) + /// All persisted hook cards for a session, oldest first, so a reload rebuilds + /// them in the order they were originally inserted. + func loadHookCards(sessionId: String) -> [HookCardRecord] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.sessionId == sessionId }, + sortBy: [SortDescriptor(\.createdAt, order: .forward)] + ) + return (try? context.fetch(descriptor)) ?? [] } - private func fetchHookStatus(sessionId: String) -> HookStatusRecord? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.sessionId == sessionId } + private func fetchHookCard(toolId: String) -> HookCardRecord? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.toolId == toolId } ) descriptor.fetchLimit = 1 return (try? context.fetch(descriptor))?.first } - /// Upsert the last hook card for a session (one row per session). - func setHookStatus( + /// Insert (or update) a hook card row keyed by its tool-call id. Called at + /// insert time (in-progress) and again on completion so the card survives a + /// reload at any point in its lifecycle. + func upsertHookCard( sessionId: String, toolId: String, - name: String, - trigger: String, - output: String, + toolName: String, + input: [String: JSONValue], + result: String?, isError: Bool, - isComplete: Bool = true + isComplete: Bool ) { - if let existing = fetchHookStatus(sessionId: sessionId) { - existing.toolId = toolId - existing.name = name - existing.trigger = trigger - existing.output = output + let data = (try? JSONEncoder().encode(input)) ?? Data() + if let existing = fetchHookCard(toolId: toolId) { + existing.sessionId = sessionId + existing.toolName = toolName + existing.inputData = data + existing.result = result existing.isError = isError existing.isComplete = isComplete existing.updatedAt = .now } else { - context.insert(HookStatusRecord( - sessionId: sessionId, + context.insert(HookCardRecord( toolId: toolId, - name: name, - trigger: trigger, - output: output, + sessionId: sessionId, + toolName: toolName, + inputData: data, + result: result, isError: isError, isComplete: isComplete )) @@ -629,6 +660,58 @@ final class ThreadStore { save() } + /// Update a previously-inserted card's result/state on completion, + /// preserving its stored `toolName`/`input`. Returns `false` (no-op) if the + /// row isn't found, so callers can fall back to an insert. + @discardableResult + func completeHookCard(toolId: String, result: String, isError: Bool, isComplete: Bool = true) -> Bool { + guard let row = fetchHookCard(toolId: toolId) else { return false } + row.result = result + row.isError = isError + row.isComplete = isComplete + row.updatedAt = .now + save() + return true + } + + /// Finalize any hook card rows left "in progress" by a previous launch (the + /// app closed while a long-running hook like code review was still running). + /// Without this they would rebuild as a spinner that never resolves. + func finalizeInterruptedHookCards() { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.isComplete == false } + ) + guard let rows = try? context.fetch(descriptor), !rows.isEmpty else { return } + for row in rows { + row.isComplete = true + row.isError = true + if (row.result ?? "").isEmpty { + row.result = "Interrupted — the app closed while this hook was running." + } + row.updatedAt = .now + } + save() + } + + private func renameHookCards(from oldId: String, to newId: String) { + guard oldId != newId else { return } + for row in loadHookCards(sessionId: oldId) { row.sessionId = newId } + } + + private func deleteHookCardRows(sessionId: String) { + for row in loadHookCards(sessionId: sessionId) { context.delete(row) } + } + + // MARK: - Hook Status (legacy) + + private func fetchHookStatus(sessionId: String) -> HookStatusRecord? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.sessionId == sessionId } + ) + descriptor.fetchLimit = 1 + return (try? context.fetch(descriptor))?.first + } + /// Finalize any hook rows left "in progress" by a previous launch (the app /// closed while a long-running hook like code review was still streaming). /// Without this they would rebuild as a spinner that never resolves. diff --git a/RxCode/Views/Settings/CustomMenuEditorSheet.swift b/RxCode/Views/Settings/CustomMenuEditorSheet.swift new file mode 100644 index 00000000..a39a545a --- /dev/null +++ b/RxCode/Views/Settings/CustomMenuEditorSheet.swift @@ -0,0 +1,271 @@ +import SwiftUI +import RxCodeCore + +/// Mutable editing state for a custom menu item, decoupled from the SwiftData +/// `@Model` so the form can edit freely and only commit on Save. +struct CustomMenuDraft: Identifiable { + let id: String + var isNew: Bool + var title: String + var systemImage: String + var projectId: UUID? // nil = all projects + var surface: CustomMenuItemRecord.Surface + var actionKind: CustomMenuItemRecord.ActionKind + + // callAPI + var httpMethod: String + var urlString: String + var headers: [HeaderPair] + var bodyTemplate: String + + // create / continue thread + var messageTemplate: String + var targetSessionId: String + + var isEnabled: Bool + var sortOrder: Int + + struct HeaderPair: Identifiable, Hashable { + let id = UUID() + var name: String + var value: String + } + + /// A fresh draft for a new item. + init() { + id = UUID().uuidString + isNew = true + title = "" + systemImage = "bolt" + projectId = nil + surface = .project + actionKind = .callAPI + httpMethod = "POST" + urlString = "" + headers = [] + bodyTemplate = "" + messageTemplate = "" + targetSessionId = "" + isEnabled = true + sortOrder = 0 + } + + /// A draft seeded from an existing record (edit flow). + init(record: CustomMenuItemRecord) { + id = record.id + isNew = false + title = record.title + systemImage = record.systemImage ?? "bolt" + projectId = record.projectId + surface = record.surfaceValue + actionKind = record.actionKindValue + httpMethod = record.httpMethod ?? "POST" + urlString = record.urlString ?? "" + headers = record.headers.map { HeaderPair(name: $0.key, value: $0.value) } + bodyTemplate = record.bodyTemplate ?? "" + messageTemplate = record.messageTemplate ?? "" + targetSessionId = record.targetSessionId ?? "" + isEnabled = record.isEnabled + sortOrder = record.sortOrder + } + + var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } + + var isValid: Bool { + guard !trimmedTitle.isEmpty else { return false } + switch actionKind { + case .callAPI: return !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .createThread: return !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .continueThread: + guard !messageTemplate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + // A thread-menu item continues the tapped thread, so no explicit target + // is needed; project/briefing items must name the thread to continue. + if surface == .thread { return true } + return !targetSessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + func toRecord() -> CustomMenuItemRecord { + let headerMap = headers.reduce(into: [String: String]()) { acc, pair in + let name = pair.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + acc[name] = pair.value + } + return CustomMenuItemRecord( + id: id, + title: trimmedTitle, + systemImage: systemImage.isEmpty ? nil : systemImage, + projectId: projectId, + surface: surface, + actionKind: actionKind, + httpMethod: actionKind == .callAPI ? httpMethod : nil, + urlString: actionKind == .callAPI ? urlString.trimmingCharacters(in: .whitespacesAndNewlines) : nil, + headersJSON: actionKind == .callAPI ? CustomMenuItemRecord.encodeHeaders(headerMap) : nil, + bodyTemplate: actionKind == .callAPI ? bodyTemplate : nil, + messageTemplate: actionKind == .callAPI ? nil : messageTemplate, + targetSessionId: actionKind == .continueThread ? targetSessionId.trimmingCharacters(in: .whitespacesAndNewlines) : nil, + isEnabled: isEnabled, + sortOrder: sortOrder + ) + } +} + +/// Form for creating / editing a custom menu item. +struct CustomMenuEditorSheet: View { + @Environment(\.dismiss) private var dismiss + + @State var draft: CustomMenuDraft + let projects: [Project] + var onSave: (CustomMenuDraft) -> Void + + private let methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] + + var body: some View { + VStack(spacing: 0) { + Text(draft.isNew ? "New Menu Item" : "Edit Menu Item") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 12) + + Form { + generalSection + actionSection + switch draft.actionKind { + case .callAPI: apiSection + case .createThread, .continueThread: threadSection + } + } + .formStyle(.grouped) + + footer + } + .frame(width: 540, height: 600) + } + + // MARK: - Sections + + private var generalSection: some View { + Section("General") { + TextField("Title", text: $draft.title) + LabeledContent("Icon") { + SFSymbolPicker(symbol: $draft.systemImage) + } + .help("An SF Symbol shown beside the title, e.g. \"bolt\".") + Picker("Show in", selection: $draft.surface) { + Text("Project menu").tag(CustomMenuItemRecord.Surface.project) + Text("Thread menu").tag(CustomMenuItemRecord.Surface.thread) + Text("Briefing card menu").tag(CustomMenuItemRecord.Surface.briefing) + } + Picker("Scope", selection: $draft.projectId) { + Text("All projects").tag(UUID?.none) + ForEach(projects) { project in + Text(verbatim: project.name).tag(UUID?.some(project.id)) + } + } + } + } + + private var actionSection: some View { + Section("Action") { + Picker("When tapped", selection: $draft.actionKind) { + Text("Call external API").tag(CustomMenuItemRecord.ActionKind.callAPI) + Text("Create new thread").tag(CustomMenuItemRecord.ActionKind.createThread) + Text("Continue existing thread").tag(CustomMenuItemRecord.ActionKind.continueThread) + } + .pickerStyle(.menu) + } + } + + private var apiSection: some View { + Section("Request") { + Picker("Method", selection: $draft.httpMethod) { + ForEach(methods, id: \.self) { Text(verbatim: $0).tag($0) } + } + TextField("URL", text: $draft.urlString, prompt: Text(verbatim: "https://api.example.com/{{projectName}}")) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + + headersEditor + + VStack(alignment: .leading, spacing: 4) { + Text("Body") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + TextEditor(text: $draft.bodyTemplate) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + .frame(minHeight: 90) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + } + } + } + + private var headersEditor: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Headers") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + Spacer() + Button { + draft.headers.append(.init(name: "", value: "")) + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + } + ForEach($draft.headers) { $pair in + HStack(spacing: 6) { + TextField("Name", text: $pair.name) + .textFieldStyle(.roundedBorder) + TextField("Value", text: $pair.value) + .textFieldStyle(.roundedBorder) + Button { + draft.headers.removeAll { $0.id == pair.id } + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + } + } + } + + private var threadSection: some View { + Section(draft.actionKind == .continueThread ? "Continue thread" : "New thread") { + if draft.actionKind == .continueThread { + TextField("Target thread id", text: $draft.targetSessionId) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + .help("The session id of the thread to continue. Leave a {{sessionId}} placeholder to target the tapped thread.") + } + VStack(alignment: .leading, spacing: 4) { + Text("Message") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + TextEditor(text: $draft.messageTemplate) + .font(.system(size: ClaudeTheme.size(12))) + .frame(minHeight: 110) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + } + } + } + + private var footer: some View { + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + onSave(draft) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!draft.isValid) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } +} diff --git a/RxCode/Views/Settings/CustomMenusSettingsTab.swift b/RxCode/Views/Settings/CustomMenusSettingsTab.swift new file mode 100644 index 00000000..9042698c --- /dev/null +++ b/RxCode/Views/Settings/CustomMenusSettingsTab.swift @@ -0,0 +1,207 @@ +import SwiftUI +import RxCodeCore + +/// "Custom Context Menus" section embedded in the General settings tab for +/// creating and managing user-defined context-menu items. +/// Items are persisted on the desktop (`CustomMenuItemRecord`) and surfaced by +/// `CustomMenuHook` on the project / thread / briefing menus — and, because they +/// ride the existing `MenuItem` relay, on mobile too. +struct CustomMenusSettingsSection: View { + @Environment(AppState.self) private var appState + + @State private var items: [CustomMenuItemRecord] = [] + @State private var editing: CustomMenuDraft? + @State private var pendingRemoval: CustomMenuItemRecord? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + placeholderHint + listSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear(perform: reload) + .sheet(item: $editing) { draft in + CustomMenuEditorSheet(draft: draft, projects: appState.projects) { result in + appState.threadStore.upsertCustomMenuItem(result.toRecord()) + reload() + } + } + .alert("Remove menu item?", isPresented: removalBinding, presenting: pendingRemoval) { record in + Button("Cancel", role: .cancel) { pendingRemoval = nil } + Button("Remove", role: .destructive) { + let id = record.id + pendingRemoval = nil + appState.threadStore.deleteCustomMenuItem(id: id) + reload() + } + } message: { record in + Text(verbatim: "“\(record.title)” will be removed from your custom menus.") + } + } + + private func reload() { + items = appState.threadStore.allCustomMenuItems() + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Custom Context Menus") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Text("Add your own items to the project, thread, and briefing-card menus. They sync to mobile automatically.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button { + editing = CustomMenuDraft() + } label: { + Label("Add Item", systemImage: "plus") + .font(.system(size: ClaudeTheme.size(12))) + } + .buttonStyle(.borderedProminent) + } + } + + private var placeholderHint: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "curlybraces") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + Text("URLs, bodies, and messages can use placeholders: {{projectName}}, {{projectPath}}, {{gitHubRepo}}, {{branch}}, {{sessionId}}.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - List + + private var listSection: some View { + VStack(alignment: .leading, spacing: 8) { + if items.isEmpty { + Text("No custom items yet.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + VStack(spacing: 6) { + ForEach(items) { record in + CustomMenuRow( + record: record, + projectName: projectName(for: record.projectId), + onEdit: { editing = CustomMenuDraft(record: record) }, + onToggle: { enabled in + record.isEnabled = enabled + appState.threadStore.upsertCustomMenuItem(record) + reload() + }, + onRemove: { pendingRemoval = record } + ) + } + } + } + } + } + + private func projectName(for id: UUID?) -> String { + guard let id else { return "All projects" } + return appState.projects.first(where: { $0.id == id })?.name ?? "Unknown project" + } + + private var removalBinding: Binding { + Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }) + } +} + +// MARK: - Row + +private struct CustomMenuRow: View { + let record: CustomMenuItemRecord + let projectName: String + let onEdit: () -> Void + let onToggle: (Bool) -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: record.systemImage ?? "circle") + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: record.title) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + HStack(spacing: 6) { + badge(surfaceLabel) + badge(actionLabel) + Text(verbatim: projectName) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.tertiary) + } + } + Spacer(minLength: 12) + Toggle("", isOn: Binding(get: { record.isEnabled }, set: { onToggle($0) })) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + Menu { + Button("Edit…", action: onEdit) + Divider() + Button("Remove…", role: .destructive, action: onRemove) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: ClaudeTheme.size(12))) + .frame(width: 28, height: 24) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func badge(_ text: String) -> some View { + Text(verbatim: text) + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + private var surfaceLabel: String { + switch record.surfaceValue { + case .project: return "Project" + case .thread: return "Thread" + case .briefing: return "Briefing" + } + } + + private var actionLabel: String { + switch record.actionKindValue { + case .callAPI: return "Call API" + case .createThread: return "New thread" + case .continueThread: return "Continue thread" + } + } +} diff --git a/RxCode/Views/Settings/SFSymbolPicker.swift b/RxCode/Views/Settings/SFSymbolPicker.swift new file mode 100644 index 00000000..fa3a77b8 --- /dev/null +++ b/RxCode/Views/Settings/SFSymbolPicker.swift @@ -0,0 +1,136 @@ +import RxCodeCore +import SwiftUI + +/// A dropdown control for choosing an SF Symbol. Shows a live preview of the +/// current selection and opens a searchable grid of common symbols. Users can +/// also type any symbol name directly for symbols not in the curated list. +struct SFSymbolPicker: View { + @Binding var symbol: String + + @State private var isPresented = false + @State private var query = "" + + /// Curated list of symbols that read well as menu-action icons. + private static let symbols: [String] = [ + "bolt", "bolt.fill", "star", "star.fill", "heart", "heart.fill", + "flag", "flag.fill", "bell", "bookmark", "tag", "tag.fill", + "paperplane", "paperplane.fill", "arrow.up.right", "arrow.up.right.square", + "link", "globe", "network", "terminal", "hammer", "wrench.and.screwdriver", + "gearshape", "gearshape.fill", "slider.horizontal.3", + "doc", "doc.text", "doc.on.doc", "folder", "tray", "tray.full", + "square.and.arrow.up", "square.and.arrow.down", "trash", "pencil", + "plus", "plus.circle", "checkmark", "checkmark.circle", "xmark", "xmark.circle", + "magnifyingglass", "message", "bubble.left", "bubble.left.and.bubble.right", + "envelope", "phone", "calendar", "clock", "alarm", + "person", "person.2", "person.crop.circle", "lock", "lock.open", "key", + "cloud", "icloud", "arrow.triangle.2.circlepath", "arrow.clockwise", + "play", "play.fill", "pause", "stop", "forward", "backward", + "sparkles", "wand.and.stars", "lightbulb", "lightbulb.fill", + "exclamationmark.triangle", "info.circle", "questionmark.circle", + "command", "ellipsis", "ellipsis.rectangle", "list.bullet", "list.bullet.rectangle", + "chart.bar", "chart.line.uptrend.xyaxis", "curlybraces", + "chevron.left.forwardslash.chevron.right", "brain", "brain.head.profile", + "cpu", "server.rack", "externaldrive", "memorychip", + "antenna.radiowaves.left.and.right", "wifi", "shippingbox", "cube", + "flame", "drop", "leaf", "moon", "sun.max", "bolt.horizontal", + ] + + private var filtered: [String] { + let q = query.trimmingCharacters(in: .whitespaces).lowercased() + guard !q.isEmpty else { return Self.symbols } + return Self.symbols.filter { $0.contains(q) } + } + + private var hasSymbol: Bool { !symbol.trimmingCharacters(in: .whitespaces).isEmpty } + + var body: some View { + Button { + isPresented.toggle() + } label: { + HStack(spacing: 8) { + Image(systemName: hasSymbol ? symbol : "questionmark.square.dashed") + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(hasSymbol ? ClaudeTheme.accent : Color.secondary) + .frame(width: 18) + Text(verbatim: hasSymbol ? symbol : "Choose…") + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + .foregroundStyle(hasSymbol ? .primary : .secondary) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: ClaudeTheme.size(9))) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + pickerBody + } + } + + private var pickerBody: some View { + VStack(spacing: 10) { + TextField("Symbol name", text: $symbol, prompt: Text(verbatim: "e.g. bolt")) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + TextField("Search symbols", text: $query) + .textFieldStyle(.plain) + .font(.system(size: ClaudeTheme.size(12))) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.fixed(40), spacing: 6), count: 6), spacing: 6) { + ForEach(filtered, id: \.self) { name in + symbolCell(name) + } + } + .padding(2) + } + .frame(height: 220) + + if filtered.isEmpty { + Text("No matches. Type a name above to use it anyway.") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + } + } + .padding(12) + .frame(width: 300) + } + + private func symbolCell(_ name: String) -> some View { + let isSelected = symbol == name + return Button { + symbol = name + isPresented = false + } label: { + Image(systemName: name) + .font(.system(size: ClaudeTheme.size(15))) + .frame(width: 38, height: 34) + .background(isSelected ? ClaudeTheme.accent.opacity(0.18) : Color(NSColor.controlBackgroundColor)) + .foregroundStyle(isSelected ? ClaudeTheme.accent : Color.primary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isSelected ? ClaudeTheme.accent : Color(NSColor.separatorColor), lineWidth: isSelected ? 1.5 : 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(name) + } +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index b32aa2ff..80daf83c 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -123,6 +123,8 @@ struct GeneralSettingsTab: View { Divider() HooksSettingsSection() Divider() + CustomMenusSettingsSection() + Divider() VStack(alignment: .leading, spacing: 8) { onboardingSection whatsNewSection diff --git a/RxCode/Views/WhatsNew/WhatsNewFeature.swift b/RxCode/Views/WhatsNew/WhatsNewFeature.swift index 2a337035..a1e5f9ef 100644 --- a/RxCode/Views/WhatsNew/WhatsNewFeature.swift +++ b/RxCode/Views/WhatsNew/WhatsNewFeature.swift @@ -47,6 +47,17 @@ struct WhatsNewFeature: Identifiable { Highlight(icon: "checkmark.seal", text: "When a Code Review hook is configured, commits only happen after the review passes."), Highlight(icon: "arrow.up.doc", text: "Push to the remote automatically, or keep the commit local.") ] + ), + WhatsNewFeature( + slug: "custom-context-menus", + title: "Custom context menus", + subtitle: "Build your own project, thread, and briefing-card menu items in Settings → Context Menus. They sync to your phone automatically.", + icon: "ellipsis.rectangle", + highlights: [ + Highlight(icon: "globe", text: "Call an external API — define the method, URL, headers, and body once, with {{placeholders}} for project and branch context."), + Highlight(icon: "bubble.left.and.bubble.right", text: "Create a new thread or continue an existing one with a templated message."), + Highlight(icon: "iphone", text: "Scope an item to one project or all of them; it shows up on desktop and mobile alike.") + ] ) ] } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt index fcb0c469..5c770bfb 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/AutopilotPayloads.kt @@ -252,9 +252,13 @@ data class AutopilotProjectBranchBody( val branch: String, ) -/** Addresses a single thread by id. Used by `threadCreateCodeReview`. */ +/** + * Addresses a single thread by id. Used by `threadCreateCodeReview` and + * `menuForThread`. `locale` (the phone's language code) makes the desktop return + * built-in menu titles translated for the device; ignored by code review. + */ @Serializable -data class AutopilotThreadBody(val sessionId: String) +data class AutopilotThreadBody(val sessionId: String, val locale: String? = null) /** * Already-decrypted secret files for `projectSecretsWrite`: the phone decrypts @@ -282,6 +286,8 @@ data class AutopilotProjectSecretsWriteBody( data class AutopilotProjectMenuBody( @Serializable(with = UuidSerializer::class) val projectId: UUID, val branch: String? = null, + /** The phone's locale (language code) so built-in titles come back translated. */ + val locale: String? = null, ) /** diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt index ad162086..0a90ed4e 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/proto/MenuModels.kt @@ -53,8 +53,34 @@ enum class MenuCommandKind { @SerialName("threadCodeReview") THREAD_CODE_REVIEW, @SerialName("threadCommitFiles") THREAD_COMMIT_FILES, @SerialName("threadStopCodeReview") THREAD_STOP_CODE_REVIEW, + @SerialName("custom") CUSTOM, } +/** Selects which kind of work a user-defined custom menu item performs. */ +@Serializable +enum class CustomMenuActionKind { + @SerialName("callAPI") CALL_API, + @SerialName("createThread") CREATE_THREAD, + @SerialName("continueThread") CONTINUE_THREAD, +} + +/** + * Mirror of Swift's `CustomMenuActionConfig`: a fully-resolved description of a + * user-defined menu action (placeholders already substituted by the desktop). + * Android only carries it so a `.custom` command round-trips back to the Mac via + * `menuExecuteCommand`; the desktop performs the work. + */ +@Serializable +data class CustomMenuActionConfig( + val kind: CustomMenuActionKind, + val httpMethod: String? = null, + val url: String? = null, + val headers: Map? = null, + val body: String? = null, + val message: String? = null, + val targetSessionId: String? = null, +) + /** * A serializable description of work for the desktop to perform. `isAsync` marks * a command that blocks on long-running work (e.g. opening a pull request); the @@ -68,6 +94,7 @@ data class MenuActionCommand( val sessionId: String? = null, val branch: String? = null, val isAsync: Boolean = false, + val custom: CustomMenuActionConfig? = null, ) /** diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt index f540eac2..ccdce982 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/AutopilotService.kt @@ -575,7 +575,7 @@ class AutopilotService( decodeResult( rawCall( AutopilotDomain.PROJECT, AutopilotOp.MENU_FOR_PROJECT, - encodeBody(AutopilotProjectMenuBody(projectId, branch)), + encodeBody(AutopilotProjectMenuBody(projectId, branch, currentLocaleCode())), ) ).items @@ -584,10 +584,17 @@ class AutopilotService( decodeResult( rawCall( AutopilotDomain.PROJECT, AutopilotOp.MENU_FOR_THREAD, - encodeBody(AutopilotThreadBody(sessionId)), + encodeBody(AutopilotThreadBody(sessionId, currentLocaleCode())), ) ).items + /** + * The device's current language code (e.g. "ko", "zh-Hans-CN"), sent with menu + * requests so the desktop returns built-in titles translated for the device. + */ + private fun currentLocaleCode(): String = + java.util.Locale.getDefault().toLanguageTag() + /** * Run a tapped [MenuActionCommand] on the Mac through its shared dispatcher, * returning a thread to navigate to and/or a URL to open on the phone. diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt index f19d594d..4e111bbc 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/chat/ChatScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.QuestionAnswer import androidx.compose.material.icons.outlined.QueuePlayNext +import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -82,6 +83,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.ChatMessage import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuCommandKind +import app.rxlab.rxcode.ui.autopilot.commandLoadingText import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.MessageBlock import app.rxlab.rxcode.proto.PendingQuestionPayload @@ -153,17 +156,55 @@ fun ChatScreen( threadMenu = if (isDraftSessionId(resolvedId)) null else runCatching { viewModel.fetchThreadMenu(resolvedId) }.getOrNull() } + // Drives the loading dialog while an async thread command (e.g. a custom API + // call) runs; `dispatchingThreadCommand` guards re-entrancy and `threadCommandError` + // surfaces a failure to the user instead of silently swallowing it. Mirrors + // `ProjectActionsMenu`. + var dispatchingThreadCommand by remember { mutableStateOf(false) } + var asyncThreadCommand by remember { mutableStateOf(null) } + var threadCommandError by remember { mutableStateOf(null) } fun runThreadAction(action: MenuAction?) { val command = (action as? MenuAction.Command)?.command ?: return + if (dispatchingThreadCommand) return menuExpanded = false + dispatchingThreadCommand = true + if (command.isAsync) asyncThreadCommand = command.kind menuScope.launch { - runCatching { + try { val result = viewModel.executeMenuCommand(command) viewModel.requestSnapshot("menu_command") result.threadId?.let { viewModel.selectSession(it) } + } catch (t: Throwable) { + threadCommandError = t.message ?: "Couldn't complete the action." + } finally { + dispatchingThreadCommand = false + asyncThreadCommand = null } } } + asyncThreadCommand?.let { kind -> + val (title, message) = commandLoadingText(kind) + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text(title) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Text(message) + } + }, + ) + } + threadCommandError?.let { message -> + AlertDialog( + onDismissRequest = { threadCommandError = null }, + title = { Text("Couldn't Complete") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { threadCommandError = null }) { Text("OK") } }, + ) + } + var showRunProfiles by remember { mutableStateOf(false) } var editingProfile by remember { mutableStateOf(null) } var showBrowser by remember { mutableStateOf(false) } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt index 5719d5a7..46916534 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/ui/sessions/SessionsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator @@ -47,6 +48,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -63,6 +65,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.rxlab.rxcode.proto.MenuAction +import app.rxlab.rxcode.proto.MenuCommandKind import app.rxlab.rxcode.proto.MenuItem import app.rxlab.rxcode.proto.SessionAttentionKind import app.rxlab.rxcode.proto.SessionSummary @@ -70,6 +73,7 @@ import app.rxlab.rxcode.proto.TodoItem import app.rxlab.rxcode.state.MobileAppState import app.rxlab.rxcode.state.MobileState import app.rxlab.rxcode.ui.autopilot.ProjectActionsMenu +import app.rxlab.rxcode.ui.autopilot.commandLoadingText import app.rxlab.rxcode.ui.autopilot.SerializedMenuItems import app.rxlab.rxcode.ui.sheets.NewThreadSheet import app.rxlab.rxcode.ui.sheets.RenameThreadSheet @@ -374,17 +378,55 @@ private fun SessionCard( threadMenu = runCatching { viewModel.fetchThreadMenu(session.id) }.getOrNull() } + // Drives the loading dialog while an async thread command (e.g. a custom API + // call) runs; `dispatchingThreadCommand` guards re-entrancy and `threadCommandError` + // surfaces a failure to the user instead of silently swallowing it. Mirrors + // `ProjectActionsMenu`. + var dispatchingThreadCommand by remember { mutableStateOf(false) } + var asyncThreadCommand by remember { mutableStateOf(null) } + var threadCommandError by remember { mutableStateOf(null) } fun runThreadAction(action: MenuAction?) { val command = (action as? MenuAction.Command)?.command ?: return + if (dispatchingThreadCommand) return menuOpen = false + dispatchingThreadCommand = true + if (command.isAsync) asyncThreadCommand = command.kind cardScope.launch { - runCatching { + try { val result = viewModel.executeMenuCommand(command) viewModel.requestSnapshot("menu_command") result.threadId?.let { onOpenThread(it) } + } catch (t: Throwable) { + threadCommandError = t.message ?: "Couldn't complete the action." + } finally { + dispatchingThreadCommand = false + asyncThreadCommand = null } } } + + asyncThreadCommand?.let { kind -> + val (title, message) = commandLoadingText(kind) + AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + title = { Text(title) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + Text(message) + } + }, + ) + } + threadCommandError?.let { message -> + AlertDialog( + onDismissRequest = { threadCommandError = null }, + title = { Text("Couldn't Complete") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { threadCommandError = null }) { Text("OK") } }, + ) + } ElevatedCard( modifier = Modifier .fillMaxWidth() diff --git a/RxCodeMobile/Resources/Localizable.xcstrings b/RxCodeMobile/Resources/Localizable.xcstrings index 16e09ffe..33c88775 100644 --- a/RxCodeMobile/Resources/Localizable.xcstrings +++ b/RxCodeMobile/Resources/Localizable.xcstrings @@ -2,7 +2,20 @@ "sourceLanguage" : "en", "strings" : { "" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } }, "-- --port 3000" : { "localizations" : { @@ -695,10 +708,36 @@ } }, "Add project from remote repositories" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 저장소에서 프로젝트 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从远程仓库添加项目" + } + } + } }, "Add project locally" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬에서 프로젝트 추가" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从本地添加项目" + } + } + } }, "Agent Clients" : { "localizations" : { @@ -1790,10 +1829,36 @@ } }, "Code Review" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码审查" + } + } + } }, "Code Review for %@" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 대한 코드 리뷰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "针对 %@ 的代码审查" + } + } + } }, "Codex Usage" : { "localizations" : { @@ -1840,13 +1905,52 @@ } }, "Commit All Changes" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 변경 사항 커밋" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提交所有更改" + } + } + } }, "Commit Files" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 커밋" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提交文件" + } + } + } }, "Committing Changes…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변경 사항 커밋 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在提交更改…" + } + } + } }, "Computer Status" : { "localizations" : { @@ -2581,10 +2685,36 @@ } }, "Creating environment…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경 생성 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在创建环境…" + } + } + } }, "Creating file…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 생성 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在创建文件…" + } + } + } }, "Creating Pull Request…" : { "localizations" : { @@ -2961,10 +3091,36 @@ } }, "Deleting environment…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "환경 삭제 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在删除环境…" + } + } + } }, "Deleting file…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 삭제 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在删除文件…" + } + } + } }, "Deny" : { "localizations" : { @@ -4443,7 +4599,20 @@ } }, "Hide code reviews" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 숨기기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏代码审查" + } + } + } }, "Hide values" : { "localizations" : { @@ -4662,7 +4831,20 @@ } }, "Install GitHub App" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 앱 설치" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 GitHub App" + } + } + } }, "Install RELEASE_TOKEN" : { "localizations" : { @@ -6422,10 +6604,36 @@ } }, "Node.js" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js" + } + } + } }, "Node.js Configuration" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js 구성" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node.js 配置" + } + } + } }, "Normal" : { "localizations" : { @@ -7920,7 +8128,20 @@ } }, "Saving file…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일 저장 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在保存文件…" + } + } + } }, "Scan" : { "localizations" : { @@ -8559,7 +8780,20 @@ } }, "Show %lld code reviews" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 %lld개 표시" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示 %lld 个代码审查" + } + } + } }, "Sign in to Claude Code or Codex on your Mac to see usage." : { "localizations" : { @@ -8808,7 +9042,20 @@ } }, "Starting Code Review…" : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "코드 리뷰 시작 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在启动代码审查…" + } + } + } }, "stdio" : { "localizations" : { @@ -9137,7 +9384,20 @@ } }, "The Mac is performing the requested action." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac이 요청한 작업을 수행하고 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 正在执行请求的操作。" + } + } + } }, "The Mac is pushing the branch and opening the PR." : { "localizations" : { @@ -9156,10 +9416,36 @@ } }, "The Mac is starting a Code Review thread for this branch." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac이 이 브랜치에 대한 코드 리뷰 스레드를 시작하고 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 正在为此分支启动代码审查线程。" + } + } + } }, "The Mac is starting a commit thread for this project." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac이 이 프로젝트에 대한 커밋 스레드를 시작하고 있습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 正在为此项目启动提交线程。" + } + } + } }, "The Mac returned an empty response." : { "localizations" : { @@ -9178,7 +9464,20 @@ } }, "The Mac returned an invalid GitHub App install URL." : { - + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac이 잘못된 GitHub 앱 설치 URL을 반환했습니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac 返回了无效的 GitHub App 安装 URL。" + } + } + } }, "The Mac returned an invalid pull-request URL." : { "localizations" : { @@ -10472,4 +10771,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/RxCodeMobile/State/MobileAppState+Autopilot.swift b/RxCodeMobile/State/MobileAppState+Autopilot.swift index fea52eb3..fcc8a2cc 100644 --- a/RxCodeMobile/State/MobileAppState+Autopilot.swift +++ b/RxCodeMobile/State/MobileAppState+Autopilot.swift @@ -510,17 +510,23 @@ extension MobileAppState { /// identical items. func fetchProjectMenu(projectId: UUID, branch: String? = nil) async throws -> [MenuItem] { try await autopilotSend(.project, .menuForProject, - body: AutopilotProjectMenuBody(projectId: projectId, branch: branch), + body: AutopilotProjectMenuBody(projectId: projectId, branch: branch, locale: Self.currentLocaleCode), as: AutopilotMenuResult.self).items } /// Fetches a thread's context menu from the Mac (see `fetchProjectMenu`). func fetchThreadMenu(sessionId: String) async throws -> [MenuItem] { try await autopilotSend(.project, .menuForThread, - body: AutopilotThreadBody(sessionId: sessionId), + body: AutopilotThreadBody(sessionId: sessionId, locale: Self.currentLocaleCode), as: AutopilotMenuResult.self).items } + /// The phone's current language code (e.g. "ko", "zh-Hans"), sent with menu + /// requests so the desktop returns built-in titles translated for the device. + static var currentLocaleCode: String { + Locale.preferredLanguages.first ?? Locale.current.identifier + } + /// Runs a tapped `MenuActionCommand` on the Mac through its shared menu /// dispatcher, returning a thread to navigate to and/or a URL to open. @discardableResult diff --git a/RxCodeTests/ThreadStoreHookCardTests.swift b/RxCodeTests/ThreadStoreHookCardTests.swift new file mode 100644 index 00000000..5318a38c --- /dev/null +++ b/RxCodeTests/ThreadStoreHookCardTests.swift @@ -0,0 +1,117 @@ +import XCTest +import RxCodeCore +@testable import RxCode + +/// Coverage for the per-card hook persistence sidecar (`HookCardRecord`): cards +/// survive a reload with their full payload, status updates preserve that +/// payload, and both per-session and project-scoped deletes clean up the rows. +@MainActor +final class ThreadStoreHookCardTests: XCTestCase { + + // `ThreadStore` is `@MainActor`, so its synthesized deinit hops executors via + // the Swift concurrency runtime — which double-frees when a local store + // deallocates at a test's return (an XCTest/NSInvocation teardown artifact; + // in the real app the store lives for the whole process and never deinits). + // Retain every store created here for the test process so that deinit never + // runs mid-run. + private static var retainedStores: [ThreadStore] = [] + private func makeStore() -> ThreadStore { + let store = ThreadStore.inMemory() + Self.retainedStores.append(store) + return store + } + + private func reviewCardInput(_ summary: String) -> [String: JSONValue] { + [ + "name": .string("Code Review"), + "trigger": .string("After Session Stop"), + "summary": .string(summary), + ] + } + + private func decodedInput(_ record: HookCardRecord) throws -> [String: JSONValue] { + try JSONDecoder().decode([String: JSONValue].self, from: record.inputData) + } + + func testHookCardsRebuildInInsertionOrderWithFullPayload() throws { + let store = makeStore() + let sid = "sess-cards" + + store.upsertHookCard( + sessionId: sid, toolId: "t1", toolName: "Hook: Code Review", + input: reviewCardInput("3 changed file(s)"), + result: nil, isError: false, isComplete: false + ) + store.upsertHookCard( + sessionId: sid, toolId: "t2", toolName: ToolCall.autoContinueToolName, + input: ["summary": .string("continuing")], + result: "addressed", isError: false, isComplete: true + ) + + let cards = store.loadHookCards(sessionId: sid) + XCTAssertEqual(cards.map(\.toolId), ["t1", "t2"], "oldest-first by createdAt") + + // The full inserted payload (including `summary`) round-trips, so the + // rebuilt card carries the same info it was inserted with. + XCTAssertEqual(try decodedInput(cards[0])["summary"], .string("3 changed file(s)")) + XCTAssertNil(cards[0].result, "in-progress card has no result yet") + XCTAssertFalse(cards[0].isComplete) + XCTAssertEqual(cards[1].result, "addressed") + } + + func testCompleteHookCardPreservesToolNameAndInput() throws { + let store = makeStore() + let sid = "sess-complete" + + store.upsertHookCard( + sessionId: sid, toolId: "t1", toolName: "Hook: Code Review", + input: reviewCardInput("2 changed file(s)"), + result: nil, isError: false, isComplete: false + ) + + // Mirrors the controller's `persistHookStatus` path: only status fields + // change; `toolName`/`input` must be preserved (regression for the bug + // where the legacy payload clobbered the inserted one). + XCTAssertTrue(store.completeHookCard(toolId: "t1", result: "✅ passed", isError: false, isComplete: true)) + + let card = try XCTUnwrap(store.loadHookCards(sessionId: sid).first) + XCTAssertEqual(card.toolName, "Hook: Code Review") + XCTAssertEqual(card.result, "✅ passed") + XCTAssertTrue(card.isComplete) + XCTAssertEqual(try decodedInput(card)["summary"], .string("2 changed file(s)")) + + // No matching row → no-op, reported via `false` so callers can fall back. + XCTAssertFalse(store.completeHookCard(toolId: "missing", result: "x", isError: false)) + } + + func testDeletingSessionRemovesHookCards() { + let store = makeStore() + let sid = "sess-del" + store.context.insert(ChatThread(id: sid, projectId: UUID())) + store.save() + store.upsertHookCard( + sessionId: sid, toolId: "t1", toolName: "x", input: [:], + result: "r", isError: false, isComplete: true + ) + XCTAssertEqual(store.loadHookCards(sessionId: sid).count, 1) + + store.delete(id: sid) + XCTAssertTrue(store.loadHookCards(sessionId: sid).isEmpty, "session delete must not leak hook cards") + } + + func testProjectScopedDeleteAllRemovesHookCards() { + let store = makeStore() + let projectId = UUID() + let sid = "sess-proj-del" + store.context.insert(ChatThread(id: sid, projectId: projectId)) + store.save() + store.upsertHookCard( + sessionId: sid, toolId: "t1", toolName: "x", input: [:], + result: "r", isError: false, isComplete: true + ) + XCTAssertEqual(store.loadHookCards(sessionId: sid).count, 1) + + store.deleteAll(projectId: projectId) + XCTAssertTrue(store.loadHookCards(sessionId: sid).isEmpty, "project delete must not leak hook cards") + } +}