From 3b5887d6feab3fdba77448fa654a53b23363540d Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:53:19 +0800 Subject: [PATCH 1/2] fix(hooks): persist every hook card so it survives an app reload Hook cards never reach the CLI jsonl transcript, so they were rebuilt from a SwiftData sidecar on reload. That sidecar (HookStatusRecord) kept only one row per session, so only the last card survived and cards inserted without an explicit persist call (countdown, commit, send, auto-continue) were lost entirely. Add a per-card HookCardRecord (keyed by toolId, storing the full input payload) and persist through the insertCard/completeCard seam, so every added card survives a reload. Rebuild merges cards back at their original chronological position via createdAt instead of appending to the bottom. persistHookStatus now only updates status fields, preserving the inserted toolName/input. Wire the new model into rename, per-session delete, and project-scoped deleteAll cleanup. Add ThreadStoreHookCardTests covering payload round-trip, status-update preservation, and delete cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RxCodeCore/Models/HookCardRecord.swift | 62 ++++++++ RxCode.xcodeproj/project.pbxproj | 4 + RxCode/App/AppState+Hooks.swift | 129 +++++++++++------ .../Hooks/AppStateHookController.swift | 57 ++++++-- RxCode/Services/ThreadStore.swift | 137 ++++++++++++++---- RxCodeTests/ThreadStoreHookCardTests.swift | 117 +++++++++++++++ 6 files changed, 427 insertions(+), 79 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/HookCardRecord.swift create mode 100644 RxCodeTests/ThreadStoreHookCardTests.swift 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/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/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/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/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") + } +} From a0592eb4a25455901b2b3353c3c662bb4c8ac52f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:54:57 +0800 Subject: [PATCH 2/2] feat(menus): add custom context menus with cross-platform sync Implement user-defined custom context menus scoped per project, with three action types (external API, create thread, continue thread), placeholder substitution, and locale-aware i18n for built-in menu titles. Add desktop settings UI (editor sheet + settings tab), persistence via ThreadStore, and sync support across macOS, iOS, and Android. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Resources/Localizable.xcstrings | 89 +- .../RxCodeCore/Hooks/HookController.swift | 11 + .../RxCodeCore/Hooks/HookPayloads.swift | 12 +- .../Sources/RxCodeCore/Menu/MenuItem.swift | 57 +- .../Models/CustomMenuItemRecord.swift | 110 ++ .../Protocol/Payload+Autopilot.swift | 14 +- .../Tests/RxCodeCoreTests/MenuItemTests.swift | 26 + RxCode/App/AppState+MenuDispatch.swift | 68 + RxCode/App/AppState+Messaging.swift | 4 + RxCode/App/AppState+MobileAutopilot.swift | 4 +- RxCode/App/AppState.swift | 8 + RxCode/Resources/Localizable.xcstrings | 1432 +++++++++++++++-- RxCode/Services/Hooks/MenuLocalizer.swift | 53 + .../Hooks/hooks/ActionsMenuHook.swift | 21 +- .../Hooks/hooks/AutopilotDocsHook.swift | 8 +- .../Hooks/hooks/AutopilotReleaseHook.swift | 10 +- .../Hooks/hooks/AutopilotSecretsHook.swift | 10 +- .../Services/Hooks/hooks/CIUpdateHook.swift | 8 +- .../Services/Hooks/hooks/CodeReviewHook.swift | 35 +- .../Services/Hooks/hooks/CustomMenuHook.swift | 125 ++ RxCode/Services/ThreadStore+CustomMenus.swift | 70 + .../Settings/CustomMenuEditorSheet.swift | 271 ++++ .../Settings/CustomMenusSettingsTab.swift | 207 +++ RxCode/Views/Settings/SFSymbolPicker.swift | 136 ++ RxCode/Views/SettingsView.swift | 2 + RxCode/Views/WhatsNew/WhatsNewFeature.swift | 11 + .../rxlab/rxcode/proto/AutopilotPayloads.kt | 10 +- .../java/app/rxlab/rxcode/proto/MenuModels.kt | 27 + .../rxlab/rxcode/state/AutopilotService.kt | 11 +- .../app/rxlab/rxcode/ui/chat/ChatScreen.kt | 43 +- .../rxcode/ui/sessions/SessionsScreen.kt | 44 +- RxCodeMobile/Resources/Localizable.xcstrings | 347 +++- .../State/MobileAppState+Autopilot.swift | 10 +- 33 files changed, 3098 insertions(+), 196 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/CustomMenuItemRecord.swift create mode 100644 RxCode/Services/Hooks/MenuLocalizer.swift create mode 100644 RxCode/Services/Hooks/hooks/CustomMenuHook.swift create mode 100644 RxCode/Services/ThreadStore+CustomMenus.swift create mode 100644 RxCode/Views/Settings/CustomMenuEditorSheet.swift create mode 100644 RxCode/Views/Settings/CustomMenusSettingsTab.swift create mode 100644 RxCode/Views/Settings/SFSymbolPicker.swift 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/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/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/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/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