diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index b41e49cb..adfe5ff8 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -37,6 +37,45 @@ public protocol HookController: AnyObject { func project(for id: UUID) -> Project? /// The display title of a session (from its summary), if known. func sessionTitle(sessionId: String) -> String? + + // MARK: Thread linkage / cross-thread sends (code-review & commit hooks) + + /// Whether the thread should skip all lifecycle hooks (e.g. a review thread). + func threadSkipsHooks(sessionId: String) -> Bool + /// The model id stored on a thread, if any (used as the review thread's + /// default model). + func threadModel(sessionId: String) -> String? + /// Distinct paths of files edited during the thread. + func changedFilePaths(sessionId: String) -> [String] + /// The first user prompt text of a thread, if any. + func firstUserPrompt(sessionId: String) -> String? + /// A readable transcript of a thread's assistant activity (text + tool-call + /// summaries), used to fold a review thread's full content into a card on the + /// parent thread. Excludes the injected instruction prompt. + func threadTranscript(sessionId: String) -> String + /// Spawn a new linked thread (e.g. `[Code Review]`) that runs no hooks, and + /// wait for its first response. Returns the resolved thread id + assistant + /// text, or `nil` if it could not be sent. + func spawnLinkedThread( + projectId: UUID, + parentThreadId: String, + label: String, + model: String?, + prompt: String, + timeoutSeconds: TimeInterval + ) async -> HookLinkedThreadResult? + /// Send a follow-up prompt into an existing thread without waiting. + func sendThreadMessage(sessionId: String, prompt: String) + /// Record the latest review verdict for a session (shared between the + /// code-review and commit hooks within one stop cycle). + func setReviewPassed(_ passed: Bool, sessionId: String) + /// The latest recorded review verdict for a session, or `nil` if none. + func reviewPassed(sessionId: String) -> Bool? + /// Number of failed-review re-prompt rounds so far for a session (bounds the + /// review→fix→review loop). + func reviewRound(sessionId: String) -> Int + /// Set the failed-review round counter for a session. + func setReviewRound(_ round: Int, sessionId: String) /// First-sentence fallback body for a response-complete notification. func responseNotificationFallback(from responseText: String) -> String /// Optionally generate an AI summary of the response for a notification body. @@ -191,6 +230,25 @@ public protocol HookController: AnyObject { public enum HookSetupKind { public static let release = "release" public static let docs = "docs" + /// Marks the follow-up turn the Commit & Push hook itself triggered, so the + /// hook short-circuits on that turn instead of looping forever. + public static let commitPush = "commitPush" +} + +/// Result of spawning a linked thread via `spawnLinkedThread`. +public struct HookLinkedThreadResult: Sendable { + /// The resolved (non-`pending-`) thread id of the spawned thread. + public let threadId: String + /// The reviewer/agent's first response text (empty on timeout). + public let assistantText: String + /// A transport/agent error, if any. + public let error: String? + + public init(threadId: String, assistantText: String, error: String?) { + self.threadId = threadId + self.assistantText = assistantText + self.error = error + } } // MARK: - Banner surfaces diff --git a/Packages/Sources/RxCodeCore/Models/ChatSession.swift b/Packages/Sources/RxCodeCore/Models/ChatSession.swift index 70fdc696..36e19e27 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatSession.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatSession.swift @@ -20,6 +20,17 @@ public struct ChatSession: Identifiable, Codable, Sendable { public var isArchived: Bool public var archivedAt: Date? + /// Id of the thread this thread was spawned from (e.g. a `[Code Review]` + /// thread links back to the thread whose change it reviews). `nil` for + /// ordinary user-started threads. + public var parentThreadId: String? + /// Short label shown as a chip in the UI (e.g. `"Code Review"`). `nil` for + /// ordinary threads. + public var threadLabel: String? + /// When true, lifecycle hooks are skipped for this thread (review threads + /// must not themselves trigger reviews/commits and loop). + public var skipHooks: Bool + public init( id: String, projectId: UUID, @@ -36,7 +47,10 @@ public struct ChatSession: Identifiable, Codable, Sendable { worktreePath: String? = nil, worktreeBranch: String? = nil, isArchived: Bool = false, - archivedAt: Date? = nil + archivedAt: Date? = nil, + parentThreadId: String? = nil, + threadLabel: String? = nil, + skipHooks: Bool = false ) { self.id = id self.projectId = projectId @@ -54,10 +68,13 @@ public struct ChatSession: Identifiable, Codable, Sendable { self.worktreeBranch = worktreeBranch self.isArchived = isArchived self.archivedAt = archivedAt + self.parentThreadId = parentThreadId + self.threadLabel = threadLabel + self.skipHooks = skipHooks } private enum CodingKeys: String, CodingKey { - case id, projectId, title, messages, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt + case id, projectId, title, messages, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt, parentThreadId, threadLabel, skipHooks } public init(from decoder: Decoder) throws { @@ -78,6 +95,9 @@ public struct ChatSession: Identifiable, Codable, Sendable { worktreeBranch = try container.decodeIfPresent(String.self, forKey: .worktreeBranch) isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt) + parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId) + threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel) + skipHooks = try container.decodeIfPresent(Bool.self, forKey: .skipHooks) ?? false } public struct Summary: Identifiable, Codable, Sendable, Equatable { @@ -96,6 +116,9 @@ public struct ChatSession: Identifiable, Codable, Sendable { public var worktreeBranch: String? public var isArchived: Bool public var archivedAt: Date? + public var parentThreadId: String? + public var threadLabel: String? + public var skipHooks: Bool public init( id: String, @@ -112,7 +135,10 @@ public struct ChatSession: Identifiable, Codable, Sendable { worktreePath: String? = nil, worktreeBranch: String? = nil, isArchived: Bool = false, - archivedAt: Date? = nil + archivedAt: Date? = nil, + parentThreadId: String? = nil, + threadLabel: String? = nil, + skipHooks: Bool = false ) { self.id = id self.projectId = projectId @@ -129,10 +155,13 @@ public struct ChatSession: Identifiable, Codable, Sendable { self.worktreeBranch = worktreeBranch self.isArchived = isArchived self.archivedAt = archivedAt + self.parentThreadId = parentThreadId + self.threadLabel = threadLabel + self.skipHooks = skipHooks } private enum CodingKeys: String, CodingKey { - case id, projectId, title, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt + case id, projectId, title, createdAt, updatedAt, isPinned, agentProvider, model, effort, permissionMode, origin, worktreePath, worktreeBranch, isArchived, archivedAt, parentThreadId, threadLabel, skipHooks } public init(from decoder: Decoder) throws { @@ -152,6 +181,9 @@ public struct ChatSession: Identifiable, Codable, Sendable { worktreeBranch = try container.decodeIfPresent(String.self, forKey: .worktreeBranch) isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false archivedAt = try container.decodeIfPresent(Date.self, forKey: .archivedAt) + parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId) + threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel) + skipHooks = try container.decodeIfPresent(Bool.self, forKey: .skipHooks) ?? false } } @@ -171,7 +203,10 @@ public struct ChatSession: Identifiable, Codable, Sendable { worktreePath: worktreePath, worktreeBranch: worktreeBranch, isArchived: isArchived, - archivedAt: archivedAt + archivedAt: archivedAt, + parentThreadId: parentThreadId, + threadLabel: threadLabel, + skipHooks: skipHooks ) } diff --git a/Packages/Sources/RxCodeCore/Models/ChatThread.swift b/Packages/Sources/RxCodeCore/Models/ChatThread.swift index 6bf47f56..afec8d88 100644 --- a/Packages/Sources/RxCodeCore/Models/ChatThread.swift +++ b/Packages/Sources/RxCodeCore/Models/ChatThread.swift @@ -23,6 +23,14 @@ public final class ChatThread { public var isArchived: Bool = false public var archivedAt: Date? + /// Id of the thread this thread was spawned from (e.g. a `[Code Review]` + /// thread links back to the reviewed thread). Defaulted for clean migration. + public var parentThreadId: String? = nil + /// Short label chip shown in the UI (e.g. `"Code Review"`). + public var threadLabel: String? = nil + /// When true, lifecycle hooks are skipped for this thread. + public var skipHooks: Bool = false + public init( id: String, projectId: UUID, @@ -39,7 +47,10 @@ public final class ChatThread { worktreePath: String? = nil, worktreeBranch: String? = nil, isArchived: Bool = false, - archivedAt: Date? = nil + archivedAt: Date? = nil, + parentThreadId: String? = nil, + threadLabel: String? = nil, + skipHooks: Bool = false ) { self.id = id self.projectId = projectId @@ -57,6 +68,9 @@ public final class ChatThread { self.worktreeBranch = worktreeBranch self.isArchived = isArchived self.archivedAt = archivedAt + self.parentThreadId = parentThreadId + self.threadLabel = threadLabel + self.skipHooks = skipHooks } } @@ -81,7 +95,10 @@ extension ChatThread { worktreePath: worktreePath, worktreeBranch: worktreeBranch, isArchived: isArchived, - archivedAt: archivedAt + archivedAt: archivedAt, + parentThreadId: parentThreadId, + threadLabel: threadLabel, + skipHooks: skipHooks ) } @@ -98,6 +115,9 @@ extension ChatThread { worktreeBranch = summary.worktreeBranch isArchived = summary.isArchived archivedAt = summary.archivedAt + parentThreadId = summary.parentThreadId + threadLabel = summary.threadLabel + skipHooks = summary.skipHooks } public static func from(_ summary: ChatSession.Summary, cliSessionId: String? = nil) -> ChatThread { @@ -117,7 +137,10 @@ extension ChatThread { worktreePath: summary.worktreePath, worktreeBranch: summary.worktreeBranch, isArchived: summary.isArchived, - archivedAt: summary.archivedAt + archivedAt: summary.archivedAt, + parentThreadId: summary.parentThreadId, + threadLabel: summary.threadLabel, + skipHooks: summary.skipHooks ) } } diff --git a/Packages/Sources/RxCodeCore/Models/HookProfile.swift b/Packages/Sources/RxCodeCore/Models/HookProfile.swift index 2d485404..8d831a88 100644 --- a/Packages/Sources/RxCodeCore/Models/HookProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/HookProfile.swift @@ -21,22 +21,78 @@ public enum HookTrigger: String, Codable, Sendable, CaseIterable, Hashable { } } -/// What a hook runs. Bash-only today; typed so other kinds can be added later. -public enum HookType: String, Codable, Sendable, CaseIterable, Hashable { - case bash +/// What a hook *does* when it fires. `command` is the original behavior — run a +/// shell/build command (`type` + the per-type configs below). The other cases +/// are built-in automations that don't run a command at all and are restricted +/// to the session-stop lifecycle. +public enum HookAction: String, Codable, Sendable, CaseIterable, Hashable { + /// Run a `RunProfileType` command (bash / xcode / make / package). + case command + /// Send the change to a linked `[Code Review]` thread for review; a failing + /// review feeds its notes back to the agent. Pairs with `.afterSessionStop`. + case codeReview + /// Instruct the agent to commit the changed files and push. Pairs with + /// `.afterSessionStop`. + case commitPush + + public var displayName: String { + switch self { + case .command: return "Command" + case .codeReview: return "Code Review" + case .commitPush: return "Commit & Push" + } + } + + /// The single lifecycle a built-in action is allowed to run at, or `nil` + /// for `.command` (which supports every trigger). + public var fixedTrigger: HookTrigger? { + switch self { + case .command: return nil + case .codeReview: return .afterSessionStop + case .commitPush: return .afterSessionStop + } + } +} + +/// Per-hook configuration for the `.codeReview` action. +public struct CodeReviewConfig: Codable, Sendable, Hashable { + /// Model id for the review thread. Empty/`nil` ⇒ inherit the reviewed + /// thread's model. + public var model: String? + /// Optional extra guidance appended to the reviewer's prompt. + public var instructions: String? + + public init(model: String? = nil, instructions: String? = nil) { + self.model = model + self.instructions = instructions + } } /// A single project-scoped automation that runs a command at a session -/// lifecycle point. Modeled on `RunProfile`; reuses `BashRunConfig` for the -/// command, working directory, and environment presets. +/// lifecycle point. Mirrors `RunProfile` one-to-one: it carries the same +/// `RunProfileType` and per-type config structs so a hook can run a bash +/// command, an `xcodebuild` invocation, a `make` target, or a package script. +/// The only hook-specific fields are `enabled` and the lifecycle `trigger`. public struct HookProfile: Identifiable, Codable, Sendable, Hashable { public var id: UUID public var projectId: UUID public var name: String public var enabled: Bool public var trigger: HookTrigger - public var type: HookType + /// What the hook does. Defaults to `.command` so hooks persisted before this + /// field existed decode unchanged. + public var action: HookAction + public var type: RunProfileType public var bash: BashRunConfig + /// Populated when `type == .xcode`. Optional so hooks written before the + /// typed configs existed (bash-only) decode cleanly. + public var xcode: XcodeRunConfig? + /// Populated when `type == .make`. Optional for backward-compatible decode. + public var make: MakeRunConfig? + /// Populated when `type == .packageScript`. Optional for backward-compatible decode. + public var package: PackageRunConfig? + /// Populated when `action == .codeReview`. Optional for backward-compatible decode. + public var codeReview: CodeReviewConfig? public var createdAt: Date public var updatedAt: Date @@ -46,8 +102,13 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { name: String, enabled: Bool = true, trigger: HookTrigger = .beforeSessionStart, - type: HookType = .bash, + action: HookAction = .command, + type: RunProfileType = .bash, bash: BashRunConfig = BashRunConfig(), + xcode: XcodeRunConfig? = nil, + make: MakeRunConfig? = nil, + package: PackageRunConfig? = nil, + codeReview: CodeReviewConfig? = nil, createdAt: Date = Date(), updatedAt: Date = Date() ) { @@ -56,9 +117,54 @@ public struct HookProfile: Identifiable, Codable, Sendable, Hashable { self.name = name self.enabled = enabled self.trigger = trigger + self.action = action self.type = type self.bash = bash + self.xcode = xcode + self.make = make + self.package = package + self.codeReview = codeReview self.createdAt = createdAt self.updatedAt = updatedAt } + + private enum CodingKeys: String, CodingKey { + case id, projectId, name, enabled, trigger, action, type, bash, xcode, make, package, codeReview, createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + projectId = try container.decode(UUID.self, forKey: .projectId) + name = try container.decode(String.self, forKey: .name) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + trigger = try container.decodeIfPresent(HookTrigger.self, forKey: .trigger) ?? .beforeSessionStart + action = try container.decodeIfPresent(HookAction.self, forKey: .action) ?? .command + type = try container.decodeIfPresent(RunProfileType.self, forKey: .type) ?? .bash + bash = try container.decodeIfPresent(BashRunConfig.self, forKey: .bash) ?? BashRunConfig() + xcode = try container.decodeIfPresent(XcodeRunConfig.self, forKey: .xcode) + make = try container.decodeIfPresent(MakeRunConfig.self, forKey: .make) + package = try container.decodeIfPresent(PackageRunConfig.self, forKey: .package) + codeReview = try container.decodeIfPresent(CodeReviewConfig.self, forKey: .codeReview) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date() + } + + /// Bridge to `RunProfile` so a hook can reuse the run-profile command + /// synthesis (`RunTaskExecutor.mainCommandLines`). Hooks have no + /// before/after steps, so those are left empty. + public var asRunProfile: RunProfile { + RunProfile( + id: id, + projectId: projectId, + name: name, + type: type, + bash: bash, + xcode: xcode, + make: make, + package: package, + createdAt: createdAt, + updatedAt: updatedAt + ) + } } diff --git a/Packages/Sources/RxCodeCore/Models/RunProfile.swift b/Packages/Sources/RxCodeCore/Models/RunProfile.swift index 9cef8dc1..bc58d67f 100644 --- a/Packages/Sources/RxCodeCore/Models/RunProfile.swift +++ b/Packages/Sources/RxCodeCore/Models/RunProfile.swift @@ -6,8 +6,20 @@ public enum RunProfileType: String, Codable, Sendable, CaseIterable, Hashable { case make /// Runs a `package.json` / `deno.json` script through a selected package /// manager. Named `packageScript` to avoid the `package` access-control - /// keyword; the rawValue stays `"package"` so it renders as "Package". + /// keyword; the rawValue stays `"package"` for backward-compatible + /// persistence and mobile/Android sync. User-facing label is "Node.js". case packageScript = "package" + + /// User-facing label. `packageScript` shows as "Node.js" (the old + /// "Package" label was too broad); the rest are their capitalized names. + public var displayName: String { + switch self { + case .bash: return "Bash" + case .xcode: return "Xcode" + case .make: return "Make" + case .packageScript: return "Node.js" + } + } } public struct RunProfile: Identifiable, Codable, Sendable, Hashable { diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index ce7fd457..546f01ca 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -136,7 +136,7 @@ public enum RunTaskExecutor { /// appropriate `xcodebuild` invocation (and, for `.run`, locate the built /// `.app` from build settings and `open` it); for `.make` we synthesize /// a `make` invocation against the configured Makefile and target. - static func mainCommandLines(for profile: RunProfile, projectPath: String) -> [String] { + public static func mainCommandLines(for profile: RunProfile, projectPath: String) -> [String] { switch profile.type { case .bash: let cmd = profile.bash.command.trimmingCharacters(in: .whitespaces) diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift index a5d8069d..0b22c692 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Autopilot.swift @@ -40,6 +40,8 @@ public enum AutopilotOp: String, Codable, Sendable { // by every "add repository" flow — mirrors the desktop add sheets which all // source `secrets.listManagedRepositories`). case listManagedRepos + case cloneManagedRepo + case installGitHubAppUrl // Automation settings (schema-driven form) case automationSchema @@ -208,6 +210,16 @@ public struct AutopilotRepoIDBody: Codable, Sendable { public init(repoId: String) { self.repoId = repoId } } +public struct AutopilotCloneRepoBody: Codable, Sendable { + public let fullName: String + public init(fullName: String) { self.fullName = fullName } +} + +public struct AutopilotCloneRepoResult: Codable, Sendable { + public let project: Project + public init(project: Project) { self.project = project } +} + // MARK: - Repo setup bodies public struct AutopilotRepoSetupUpdateBody: Codable, Sendable { diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift index 95d2f7ee..3dfa079c 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift @@ -45,6 +45,11 @@ public struct SessionSummary: Codable, Sendable, Identifiable { /// "finished, unread" indicator. Defaults to `false` for summaries from /// an older desktop that predates this field. public let hasUncheckedCompletion: Bool + /// Id of the thread this thread was spawned from (e.g. a `[Code Review]` + /// thread links back to the reviewed thread). `nil` for ordinary threads. + public let parentThreadId: String? + /// Short label chip (e.g. `"Code Review"`). `nil` for ordinary threads. + public let threadLabel: String? public init( id: String, @@ -58,7 +63,9 @@ public struct SessionSummary: Codable, Sendable, Identifiable { progress: SessionProgressSnapshot? = nil, todos: [TodoItem]? = nil, queuedMessages: [QueuedUserMessage]? = nil, - hasUncheckedCompletion: Bool = false + hasUncheckedCompletion: Bool = false, + parentThreadId: String? = nil, + threadLabel: String? = nil ) { self.id = id self.projectId = projectId @@ -72,10 +79,12 @@ public struct SessionSummary: Codable, Sendable, Identifiable { self.todos = todos self.queuedMessages = queuedMessages self.hasUncheckedCompletion = hasUncheckedCompletion + self.parentThreadId = parentThreadId + self.threadLabel = threadLabel } private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion, parentThreadId, threadLabel } public init(from decoder: Decoder) throws { @@ -92,6 +101,8 @@ public struct SessionSummary: Codable, Sendable, Identifiable { todos = try container.decodeIfPresent([TodoItem].self, forKey: .todos) queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false + parentThreadId = try container.decodeIfPresent(String.self, forKey: .parentThreadId) + threadLabel = try container.decodeIfPresent(String.self, forKey: .threadLabel) } } diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 56011fa2..f04adfb5 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 */; }; + 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 */; }; 7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */; }; @@ -129,6 +130,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 = ""; }; + 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; }; 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITestplan.xctestplan; sourceTree = ""; }; @@ -340,6 +342,7 @@ DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */, 5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */, 5C3333332FCB400000000001 /* ThreadStoreThreadSummaryTests.swift */, + 5C4444442FCE100000000001 /* GeneratedTextSanitizerTests.swift */, E62000002FCB000100000001 /* MemoryIntentTests.swift */, ); path = RxCodeTests; @@ -792,6 +795,7 @@ DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */, 5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */, 5C3333332FCB400000000002 /* ThreadStoreThreadSummaryTests.swift in Sources */, + 5C4444442FCE100000000002 /* GeneratedTextSanitizerTests.swift in Sources */, E62000012FCB000100000001 /* MemoryIntentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index efe23aba..cfa3af20 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "037027ac785e88eafec21a5d2ded918dff49adb8ca86008f1fff4211c09b1c39", + "originHash" : "4e57c3e72b3783f86f3921d55aadf023d63038ca5bc9265e34900dc7724ab193", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/RxCode/App/AppState+CrossProjectSend.swift b/RxCode/App/AppState+CrossProjectSend.swift index 8450b4c0..15f635ce 100644 --- a/RxCode/App/AppState+CrossProjectSend.swift +++ b/RxCode/App/AppState+CrossProjectSend.swift @@ -38,7 +38,10 @@ extension AppState { effort: String? = nil, permissionMode: PermissionMode? = nil, waitForResponse: Bool = true, - timeoutSeconds: TimeInterval = 120 + timeoutSeconds: TimeInterval = 120, + parentThreadId: String? = nil, + threadLabel: String? = nil, + skipHooks: Bool = false ) async throws -> CrossProjectSendResult { // Resolve target project + thread. let resolvedProject: Project @@ -119,6 +122,25 @@ extension AppState { resolvedThreadIdForReturn = postSendKey } + // Stamp linkage (parent thread / label / skip-hooks) onto the freshly + // created thread now that its real id is known. Only for new threads — + // existing-thread sends (e.g. the commit message) leave linkage alone. + if resolvedThreadId == nil, + parentThreadId != nil || threadLabel != nil || skipHooks { + threadStore.setThreadLinkage( + sessionId: resolvedThreadIdForReturn, + parentThreadId: parentThreadId, + threadLabel: threadLabel, + skipHooks: skipHooks + ) + // Mirror onto the in-memory summary so the UI reflects it immediately. + if let idx = allSessionSummaries.firstIndex(where: { $0.id == resolvedThreadIdForReturn }) { + allSessionSummaries[idx].parentThreadId = parentThreadId + allSessionSummaries[idx].threadLabel = threadLabel + allSessionSummaries[idx].skipHooks = skipHooks + } + } + if !waitForResponse { // Don't leak the result in the dictionary; the caller is // fire-and-forget. Drop it once it lands. diff --git a/RxCode/App/AppState+MobileAutopilot.swift b/RxCode/App/AppState+MobileAutopilot.swift index 7c43c6c1..5eb0fb07 100644 --- a/RxCode/App/AppState+MobileAutopilot.swift +++ b/RxCode/App/AppState+MobileAutopilot.swift @@ -83,6 +83,32 @@ extension AppState { let q = optionalAutopilotBody(request, as: AutopilotReposQuery.self) let page = try await secrets.listManagedRepositories(search: q?.search, cursor: q?.cursor) return try encoder.encode(page) + case .cloneManagedRepo: + let body = try decodeAutopilotBody(request, as: AutopilotCloneRepoBody.self) + let target = body.fullName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !target.isEmpty else { + throw MobileRemoteConfigError.invalidRequest("Repository name is required.") + } + guard isSignedIn else { + throw MobileRemoteConfigError.invalidRequest("Sign in with rxlab on your Mac before importing GitHub repositories.") + } + if let existing = projects.first(where: { $0.gitHubRepo?.lowercased() == target }) { + return try encoder.encode(AutopilotCloneRepoResult(project: existing)) + } + let page = try await autopilot.listRepositories(search: body.fullName, cursor: nil) + guard let repo = page.items.first(where: { $0.fullName.lowercased() == target }) else { + throw MobileRemoteConfigError.invalidRequest("Repository is not available from the signed-in rxlab account.") + } + guard let project = try await cloneAndAddProjectForMobile(repo) else { + throw MobileRemoteConfigError.invalidRequest("Failed to add cloned repository.") + } + scheduleMobileSnapshotBroadcast() + return try encoder.encode(AutopilotCloneRepoResult(project: project)) + case .installGitHubAppUrl: + guard isSignedIn else { + throw MobileRemoteConfigError.invalidRequest("Sign in with rxlab on your Mac before installing the GitHub App.") + } + return try encoder.encode(try await autopilot.installUrl()) // MARK: Automation settings case .automationSchema: diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift index 68d00fa2..328cd32e 100644 --- a/RxCode/App/AppState+MobileSnapshots.swift +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -294,7 +294,9 @@ extension AppState { progress: progress, todos: todos, queuedMessages: queued, - hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false + hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false, + parentThreadId: summary.parentThreadId, + threadLabel: summary.threadLabel ) } diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 25b18fb5..bf9296ba 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -385,7 +385,20 @@ extension AppState { } } + @discardableResult + func cloneAndAddProjectForMobile(_ repo: AutopilotRepo) async throws -> Project? { + if let existing = projects.first(where: { $0.gitHubRepo?.lowercased() == repo.fullName.lowercased() }) { + return existing + } + return try await cloneAndRegisterProject(repo, selectingIn: nil) + } + func cloneAndAddProject(_ repo: AutopilotRepo, in window: WindowState) async throws { + _ = try await cloneAndRegisterProject(repo, selectingIn: window) + } + + @discardableResult + private func cloneAndRegisterProject(_ repo: AutopilotRepo, selectingIn window: WindowState?) async throws -> Project? { let home = FileManager.default.homeDirectoryForCurrentUser.path let clonePath = "\(home)/RxCode/\(repo.name)" let parentDir = "\(home)/RxCode" @@ -399,12 +412,16 @@ extension AppState { // auto-download run for clones too) and the more specific // `onRepositoryCloned`. No built-in hook handles both, so there is no // double-processing. - let project = await addAndSelectProject( - name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window - ) + let project: Project? + if let window { + project = await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) + } else { + project = await addProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName) + } if let project { await hookManager.dispatchRepositoryCloned(RepositoryPayload(project: project, wasCloned: true)) } + return project } private func gitClone(from url: String, to path: String) async throws { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 0c0f807b..a6b922a4 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -917,6 +917,51 @@ final class AppState { var codexInstalled = false var onboardingCompleted = UserDefaults.standard.bool(forKey: "onboardingCompleted") + // MARK: - What's New + + private static let seenWhatsNewKey = "seenWhatsNewSlugs" + + /// Whether onboarding had already been completed when this launch started. + /// Distinguishes an existing user (who should see "What's New" for newly + /// added features) from a brand-new install (for whom nothing is "new"). + let wasOnboardedAtLaunch = UserDefaults.standard.bool(forKey: "onboardingCompleted") + + /// Slugs of "What's New" feature cards the user has already seen. + private(set) var seenWhatsNewSlugs: Set = + Set(UserDefaults.standard.stringArray(forKey: AppState.seenWhatsNewKey) ?? []) + + /// Controls presentation of the What's New sheet. + var showWhatsNewSheet = false + + /// The batch of features being presented. Captured once when the sheet is + /// triggered so it stays stable while cards are marked seen mid-flow (which + /// would otherwise shrink `unseenWhatsNewFeatures` underneath the sheet). + var whatsNewBatch: [WhatsNewFeature] = [] + + /// Shipped feature cards the user has not yet seen, in display order. + var unseenWhatsNewFeatures: [WhatsNewFeature] { + WhatsNewFeature.all.filter { !seenWhatsNewSlugs.contains($0.slug) } + } + + func markWhatsNewSeen(_ slug: String) { + markWhatsNewSeen([slug]) + } + + func markWhatsNewSeen(_ slugs: S) where S.Element == String { + var changed = false + for slug in slugs where seenWhatsNewSlugs.insert(slug).inserted { + changed = true + } + guard changed else { return } + UserDefaults.standard.set(Array(seenWhatsNewSlugs), forKey: Self.seenWhatsNewKey) + } + + /// Marks every shipped feature as seen. Used for brand-new installs so the + /// "What's New" sheet never greets a first-time user. + func markAllWhatsNewSeen() { + markWhatsNewSeen(WhatsNewFeature.all.map(\.slug)) + } + // MARK: - App Initialization /// True once `initialize()` has finished its first run. UI shows a loading @@ -1065,6 +1110,15 @@ final class AppState { /// hook passes or the user sends a real message. Keyed by session id. var stopHookRepromptCounts: [String: Int] = [:] + /// Latest code-review verdict per session, set by `CodeReviewHook` and read + /// by `CommitPushHook` during the after-stop dispatch so a commit only + /// happens once review passed. Keyed by session id. + var reviewPassedBySession: [String: Bool] = [:] + + /// Number of failed-review re-prompt rounds per session, bounding the + /// review→fix→review loop (see `CodeReviewHook`). Keyed by session id. + var reviewRoundBySession: [String: Int] = [:] + func runProfiles(for projectId: UUID) -> [RunProfile] { runProfilesByProject[projectId] ?? [] } @@ -1200,6 +1254,11 @@ final class AppState { hookManager.register(AutopilotReleaseHook()) hookManager.register(CIUpdateHook()) #endif + // Registered last so their (potentially long) after-stop work runs after + // the response-complete notification has already fired. CodeReviewHook + // must come before CommitPushHook so the commit gate sees the verdict. + hookManager.register(CodeReviewHook()) + hookManager.register(CommitPushHook()) } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 8d7d9d0a..1fbb5875 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -1369,6 +1369,12 @@ } } } + }, + "Add project from remote repositories" : { + + }, + "Add project locally" : { + }, "Add Relay Server" : { "localizations" : { @@ -1573,6 +1579,9 @@ } } } + }, + "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." : { + }, "After Session Stop" : { "extractionState" : "stale", @@ -3262,6 +3271,9 @@ } } } + }, + "Code Review" : { + }, "Collapse chats" : { "localizations" : { @@ -3332,6 +3344,9 @@ } } } + }, + "Commit & Push" : { + }, "Commit %@" : { "localizations" : { @@ -5421,8 +5436,12 @@ } } } + }, + "Empty Node.js Configuration" : { + }, "Empty Package Configuration" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5840,6 +5859,9 @@ } } } + }, + "Extra instructions (optional)" : { + }, "Fact" : { "localizations" : { @@ -6579,46 +6601,46 @@ } } }, - "hook.secrets.checking" : { + "hook.pullRequest.creating" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking for secrets…" + "value" : "Creating pull request…" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "시크릿 확인 중…" + "value" : "풀 리퀘스트 생성 중…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在检查密钥…" + "value" : "正在创建拉取请求…" } } } }, - "hook.pullRequest.creating" : { + "hook.secrets.checking" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Creating pull request…" + "value" : "Checking for secrets…" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "풀 리퀘스트 생성 중…" + "value" : "시크릿 확인 중…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在创建拉取请求…" + "value" : "正在检查密钥…" } } } @@ -9468,6 +9490,9 @@ } } } + }, + "Node.js" : { + }, "Non-zero → agent continues" : { "extractionState" : "stale", @@ -9701,6 +9726,9 @@ } } } + }, + "On a clean session stop, the change is sent to a linked [Code Review] thread that runs no hooks. If the review requests changes, its notes are fed back to the agent to keep fixing (up to 3 times)." : { + }, "On failure the hook output is sent back to the agent, which keeps fixing until the hook passes (max 3 retries)." : { "localizations" : { @@ -10191,6 +10219,7 @@ } }, "Package" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -11868,6 +11897,9 @@ } } } + }, + "Review model" : { + }, "Review the CLI setup check" : { "localizations" : { @@ -12097,6 +12129,7 @@ } }, "Runs with `/bin/zsh -lc`. Absolute or project-relative working directory; leave empty to use the project root." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -12208,6 +12241,9 @@ } } } + }, + "Same as thread" : { + }, "Save" : { "localizations" : { @@ -13594,6 +13630,7 @@ } }, "Sign in to rxlab" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15527,4 +15564,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift index 62d0e748..f36f8f51 100644 --- a/RxCode/Services/ClaudeService+Summaries.swift +++ b/RxCode/Services/ClaudeService+Summaries.swift @@ -187,14 +187,8 @@ extension ClaudeCodeServer { } func cleanGeneratedSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } /// Write a one-off MCP config file (with no servers) used by the title-generation diff --git a/RxCode/Services/CodexAppServer+Summaries.swift b/RxCode/Services/CodexAppServer+Summaries.swift index 54501dec..f2b135ea 100644 --- a/RxCode/Services/CodexAppServer+Summaries.swift +++ b/RxCode/Services/CodexAppServer+Summaries.swift @@ -265,14 +265,7 @@ extension CodexAppServer { } func cleanSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } } diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index 515fb47b..de5fa84f 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -324,11 +324,6 @@ actor FoundationModelSummarizationService { } private func cleanSummary(_ raw: String?, limit: Int) -> String? { - guard let raw else { return nil } - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - return String(cleaned.prefix(limit)) + GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: []) } } diff --git a/RxCode/Services/GeneratedTextSanitizer.swift b/RxCode/Services/GeneratedTextSanitizer.swift new file mode 100644 index 00000000..5d943113 --- /dev/null +++ b/RxCode/Services/GeneratedTextSanitizer.swift @@ -0,0 +1,74 @@ +import Foundation + +enum GeneratedTextSanitizer { + static func cleanSummary(_ raw: String?, limit: Int, errorPrefixes: [String]) -> String? { + guard let raw else { return nil } + let cleaned = cleanMarkdownDocument(raw) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } + + static func cleanMarkdownDocument(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + text = stripSurroundingQuotes(text, includingBackticks: false) + text = unwrapWholeDocumentFence(text) + text = stripSurroundingQuotes(text, includingBackticks: true) + text = unwrapWholeDocumentFence(text) + text = dropLeadingMarkdownLanguageLabel(text) + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func unwrapWholeDocumentFence(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let fence = openingFence(in: trimmed) else { return trimmed } + + var lines = trimmed.components(separatedBy: "\n") + guard !lines.isEmpty else { return trimmed } + lines.removeFirst() + + if let last = lines.last, + last.trimmingCharacters(in: .whitespaces).hasPrefix(fence) { + lines.removeLast() + } + + return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func openingFence(in text: String) -> String? { + let firstLine = text.components(separatedBy: "\n").first? + .trimmingCharacters(in: .whitespaces) ?? "" + if firstLine.hasPrefix("```") { return "```" } + if firstLine.hasPrefix("~~~") { return "~~~" } + return nil + } + + private static func stripSurroundingQuotes(_ raw: String, includingBackticks: Bool) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let wrappers = includingBackticks ? "\"'`" : "\"'" + if let first = text.first, let last = text.last, + wrappers.contains(first), first == last, text.count > 1 { + text = String(text.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + private static func dropLeadingMarkdownLanguageLabel(_ raw: String) -> String { + var lines = raw.components(separatedBy: "\n") + while let first = lines.first { + let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + lines.removeFirst() + continue + } + if trimmed.lowercased() == "markdown" { + lines.removeFirst() + } + break + } + return lines.joined(separator: "\n") + } +} diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index a9e3db9d..5149cb60 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -78,6 +78,138 @@ final class AppStateHookController: HookController { app?.allSessionSummaries.first(where: { $0.id == sessionId })?.title } + // MARK: Thread linkage / cross-thread sends + + func threadSkipsHooks(sessionId: String) -> Bool { + guard let app else { return false } + if let summary = app.allSessionSummaries.first(where: { $0.id == sessionId }) { + return summary.skipHooks + } + return app.threadStore.fetch(id: sessionId)?.skipHooks ?? false + } + + func threadModel(sessionId: String) -> String? { + guard let app else { return nil } + if let model = app.allSessionSummaries.first(where: { $0.id == sessionId })?.model { + return model + } + return app.threadStore.fetch(id: sessionId)?.model + } + + func changedFilePaths(sessionId: String) -> [String] { + guard let app else { return [] } + var seen = Set() + var ordered: [String] = [] + for edit in app.threadStore.fetchFileEdits(sessionId: sessionId) where seen.insert(edit.path).inserted { + ordered.append(edit.path) + } + return ordered + } + + func firstUserPrompt(sessionId: String) -> String? { + guard let app else { return nil } + let messages = app.stateForSession(sessionId).messages + for message in messages where message.role == .user { + let text = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + } + return nil + } + + func threadTranscript(sessionId: String) -> String { + guard let app else { return "" } + let messages = app.stateForSession(sessionId).messages + var lines: [String] = [] + for message in messages { + switch message.role { + case .user: + // Skip the injected review instruction (the only user message). + continue + case .assistant: + for toolCall in message.toolCalls { + // Hook/auto-continue synthetic cards aren't part of the + // reviewer's own work — skip them. + if toolCall.name.lowercased().hasPrefix("hook:") { continue } + lines.append("• \(toolCall.name)") + } + let text = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { lines.append(text) } + default: + let text = message.content.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { lines.append(text) } + } + } + return lines.joined(separator: "\n\n") + } + + func spawnLinkedThread( + projectId: UUID, + parentThreadId: String, + label: String, + model: String?, + prompt: String, + timeoutSeconds: TimeInterval + ) async -> HookLinkedThreadResult? { + guard let app else { return nil } + do { + let result = try await app.sendCrossProject( + projectId: projectId, + threadId: nil, + prompt: prompt, + model: model, + // Plan mode keeps the reviewer read-only (no edits) and runs + // unattended without permission prompts. + permissionMode: .plan, + waitForResponse: true, + timeoutSeconds: timeoutSeconds, + parentThreadId: parentThreadId, + threadLabel: label, + skipHooks: true + ) + return HookLinkedThreadResult( + threadId: result.threadId, + assistantText: result.assistantText, + error: result.error + ) + } catch { + logger.error("spawnLinkedThread failed: \(error.localizedDescription)") + return nil + } + } + + func sendThreadMessage(sessionId: String, prompt: String) { + guard let app else { return } + Task { [weak app] in + _ = try? await app?.sendCrossProject( + projectId: nil, + threadId: sessionId, + prompt: prompt, + waitForResponse: false + ) + } + } + + func setReviewPassed(_ passed: Bool, sessionId: String) { + app?.reviewPassedBySession[sessionId] = passed + } + + func reviewPassed(sessionId: String) -> Bool? { + app?.reviewPassedBySession[sessionId] + } + + func reviewRound(sessionId: String) -> Int { + app?.reviewRoundBySession[sessionId] ?? 0 + } + + func setReviewRound(_ round: Int, sessionId: String) { + guard let app else { return } + if round == 0 { + app.reviewRoundBySession[sessionId] = nil + } else { + app.reviewRoundBySession[sessionId] = round + } + } + func responseNotificationFallback(from responseText: String) -> String { app?.responseNotificationFallback(from: responseText) ?? "" } diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift index a9c01681..7633f36a 100644 --- a/RxCode/Services/Hooks/HookManager.swift +++ b/RxCode/Services/Hooks/HookManager.swift @@ -24,6 +24,13 @@ final class HookManager { private var enabledHooks: [any Hook] { hooks.filter(\.isEnabled) } + /// Whether a thread opts out of all lifecycle hooks (e.g. a `[Code Review]` + /// thread, so it can't itself trigger a review/commit and loop). Checks both + /// the in-memory key and the resolved session id since the CLI rotates ids. + private func threadSkipsHooks(_ keys: String...) -> Bool { + keys.contains { controller.threadSkipsHooks(sessionId: $0) } + } + // MARK: - Session lifecycle func dispatchProjectNewChatStart(_ payload: NewChatStartPayload) async { @@ -49,6 +56,7 @@ final class HookManager { } func dispatchSessionStart(_ payload: SessionStartPayload) async -> HookAggregateResult { + guard !threadSkipsHooks(payload.sessionKey) else { return HookAggregateResult.fold([]) } var outcomes: [HookOutcome] = [] for hook in enabledHooks { outcomes.append(await hook.onSessionStart(payload, controller: controller)) @@ -57,6 +65,7 @@ final class HookManager { } func dispatchBeforeSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { + guard !threadSkipsHooks(payload.sessionKey, payload.sessionId) else { return HookAggregateResult.fold([]) } var outcomes: [HookOutcome] = [] for hook in enabledHooks { outcomes.append(await hook.beforeSessionEnd(payload, controller: controller)) @@ -65,6 +74,7 @@ final class HookManager { } func dispatchAfterSessionEnd(_ payload: SessionEndPayload) async -> HookAggregateResult { + guard !threadSkipsHooks(payload.sessionKey, payload.sessionId) else { return HookAggregateResult.fold([]) } var outcomes: [HookOutcome] = [] for hook in enabledHooks { outcomes.append(await hook.afterSessionEnd(payload, controller: controller)) diff --git a/RxCode/Services/Hooks/HookService.swift b/RxCode/Services/Hooks/HookService.swift index b6875a57..b5fdebaf 100644 --- a/RxCode/Services/Hooks/HookService.swift +++ b/RxCode/Services/Hooks/HookService.swift @@ -21,12 +21,18 @@ enum HookService { /// store or the injected system prompt. private static let maxOutputBytes = 64 * 1024 - /// Run a bash hook. Resolves working directory and environment with the same - /// helpers `RunService` uses, then executes the command under a login zsh so - /// the user's PATH (bun, pnpm, pyenv, …) is available — matching how - /// `RunTaskExecutor` and the interactive terminal source PATH. + /// Run a hook. Synthesizes the command from the hook's type with the same + /// builder run profiles use (`RunTaskExecutor.mainCommandLines`), so a hook + /// can be bash, `xcodebuild`, `make`, or a package script. Resolves working + /// directory and environment with the same helpers `RunService` uses, then + /// executes under a login zsh so the user's PATH (bun, pnpm, pyenv, …) is + /// available — matching how `RunTaskExecutor` and the interactive terminal + /// source PATH. static func run(_ profile: HookProfile, project: Project) async -> HookRunResult { - let command = profile.bash.command.trimmingCharacters(in: .whitespacesAndNewlines) + let command = RunTaskExecutor + .mainCommandLines(for: profile.asRunProfile, projectPath: project.path) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) guard !command.isEmpty else { return HookRunResult(output: "", exitCode: 0) } diff --git a/RxCode/Services/Hooks/hooks/CodeReviewHook.swift b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift new file mode 100644 index 00000000..b8398286 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/CodeReviewHook.swift @@ -0,0 +1,219 @@ +import Foundation +import os +import RxCodeCore + +/// Built-in `.codeReview` hook. On a clean session stop, if the project has an +/// enabled Code Review hook, it spawns a linked `[Code Review]` thread (running +/// no hooks, using the configured or inherited model) and feeds it the changed +/// files, the user's task, and the agent's final response. The reviewer ends its +/// reply with `REVIEW_RESULT: PASS` or `REVIEW_RESULT: FAIL`: +/// - PASS → records the verdict so `CommitPushHook` may proceed. +/// - FAIL (or no verdict) → sends the review notes back into the original +/// thread as a follow-up prompt so the agent fixes the issues and is then +/// re-reviewed. Bounded by `maxReviewRounds` to stop a fix→fail→fix loop. +/// +/// Runs on `.afterSessionStop` (after the thread is finalized/saved). Registered +/// last so its (possibly long) work doesn't delay the response notification. +@MainActor +final class CodeReviewHook: Hook { + let hookID = "builtin.codeReview" + private let logger = Logger(subsystem: "com.claudework", category: "CodeReviewHook") + + /// Verdict marker the reviewer must end its reply with. + private static let marker = "REVIEW_RESULT:" + /// Upper bound on how long to wait for the review thread's first response. + private static let reviewTimeout: TimeInterval = 600 + /// Max failed-review re-prompts per session before giving up. + private static let maxReviewRounds = 3 + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + // Only review clean completions; errored/cancelled turns aren't reviewable. + guard payload.reason == .completed, !payload.turnDidError else { return .ignored } + + // Don't review the follow-up commit turn the Commit & Push hook triggered + // (file edits are thread-cumulative, so the original change would look + // "changed" again and spawn a redundant review). The marker is still set + // here because CommitPushHook clears it later in this same dispatch (it is + // registered after this hook). + if controller.isSetupSession(kind: HookSetupKind.commitPush, sessionKey: payload.sessionKey) { + return .ignored + } + + let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) + .filter { $0.action == .codeReview } + guard let hook = hooks.first else { return .ignored } + + let changedFiles = controller.changedFilePaths(sessionId: payload.sessionId) + guard !changedFiles.isEmpty else { + // Nothing changed — treat as passed so a paired commit hook can no-op + // cleanly rather than waiting on a review that has nothing to review. + recordVerdict(true, payload: payload, controller: controller) + return .ignored + } + + let task = controller.firstUserPrompt(sessionId: payload.sessionKey) ?? "(task unknown)" + let model = hook.codeReview?.model?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedModel = (model?.isEmpty == false) ? model : controller.threadModel(sessionId: payload.sessionId) + let prompt = reviewPrompt( + task: task, + changedFiles: changedFiles, + finalResponse: payload.lastAssistantText, + extraInstructions: hook.codeReview?.instructions + ) + + let card = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + "summary": .string("Code review · \(changedFiles.count) changed file(s)"), + ] + ) + + logger.debug("[Hook] spawning code-review thread for session \(payload.sessionId, privacy: .public) files=\(changedFiles.count)") + let result = await controller.spawnLinkedThread( + projectId: payload.project.id, + parentThreadId: payload.sessionId, + label: "Code Review", + model: resolvedModel, + prompt: prompt, + timeoutSeconds: Self.reviewTimeout + ) + + guard let result, result.error == nil else { + // Couldn't run the review (transport/agent error or timeout). Don't + // punish the agent for our own failure — record as not-passed (so a + // commit hook holds off) but don't re-prompt. + let detail = result?.error ?? "The review thread did not respond in time." + finishCard(card, hook: hook, payload: payload, controller: controller, + result: "Code review could not complete: \(detail)", isError: true) + recordVerdict(false, payload: payload, controller: controller) + return .ignored + } + + let verdict = parseVerdict(result.assistantText) + // Fold the review thread's full activity into the card so it can be + // expanded inline on the parent thread (and on paired mobile devices, + // since hook cards sync automatically). + let transcript = controller.threadTranscript(sessionId: result.threadId) + let body = transcript.isEmpty ? result.assistantText : transcript + let reviewLink = "Review thread: \(result.threadId)" + + switch verdict { + case .pass: + finishCard(card, hook: hook, payload: payload, controller: controller, + result: "✅ Code review passed.\n\(reviewLink)\n\n\(body)", isError: false) + recordVerdict(true, payload: payload, controller: controller) + controller.setReviewRound(0, sessionId: payload.sessionId) + return .proceed + + case .fail(let notes), .unknown(let notes): + recordVerdict(false, payload: payload, controller: controller) + let round = controller.reviewRound(sessionId: payload.sessionId) + if round + 1 >= Self.maxReviewRounds { + // Give up re-prompting after the cap so a perpetually-failing + // review can't loop the agent forever. + controller.setReviewRound(0, sessionId: payload.sessionId) + finishCard(card, hook: hook, payload: payload, controller: controller, + result: "⚠️ Code review still requesting changes after \(Self.maxReviewRounds) attempts — stopping.\n\(reviewLink)\n\n\(body)", + isError: true) + return .proceed + } + + controller.setReviewRound(round + 1, sessionId: payload.sessionId) + finishCard(card, hook: hook, payload: payload, controller: controller, + result: "⚠️ Code review requested changes (sent back to the agent, attempt \(round + 1) of \(Self.maxReviewRounds)).\n\(reviewLink)\n\n\(body)", + isError: true) + // After-stop can't auto-continue, so re-prompt the thread directly. + controller.sendThreadMessage( + sessionId: payload.sessionId, + prompt: """ + A Code Review of your change requested changes. Address the feedback below, then finish. + + \(notes) + """ + ) + return .proceed + } + } + + /// Complete the card and persist it as the session's "last hook" so the + /// folded review survives an app reload (hook cards never reach the transcript). + private func finishCard( + _ card: HookCardHandle, + hook: HookProfile, + payload: SessionEndPayload, + controller: any HookController, + result: String, + isError: Bool + ) { + controller.completeCard(card, sessionKey: payload.sessionKey, result: result, isError: isError) + controller.persistHookStatus( + sessionKey: payload.sessionKey, + toolId: card.toolId, + name: hook.name, + trigger: hook.trigger.displayName, + output: result, + isError: isError + ) + } + + // MARK: - Helpers + + private func recordVerdict(_ passed: Bool, payload: SessionEndPayload, controller: any HookController) { + // Store under both the in-memory key and the resolved id; the commit hook + // reads whichever it sees first. + controller.setReviewPassed(passed, sessionId: payload.sessionId) + controller.setReviewPassed(passed, sessionId: payload.sessionKey) + } + + private enum Verdict { + case pass + case fail(notes: String) + case unknown(notes: String) + } + + private func parseVerdict(_ text: String) -> Verdict { + // Scan from the end so a trailing verdict line wins over any earlier + // mention of the marker in the body. + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + for line in lines.reversed() { + guard let range = line.range(of: Self.marker, options: [.caseInsensitive]) else { continue } + let value = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if value.hasPrefix("PASS") { return .pass } + if value.hasPrefix("FAIL") { return .fail(notes: text) } + } + // No verdict marker — be conservative and treat as needing attention. + return .unknown(notes: text) + } + + private func reviewPrompt(task: String, changedFiles: [String], finalResponse: 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. + + ## The user's task + \(task) + + ## Files the agent changed + \(fileList) + + ## The agent's final response + \(finalResponse) + + ## 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. + + End your reply with a single line — exactly one of: + `\(Self.marker) PASS` (the change is good as-is) + `\(Self.marker) FAIL` (changes are needed) + + If you FAIL the review, list the specific, actionable issues to fix above that line. + """ + if let extra = extraInstructions?.trimmingCharacters(in: .whitespacesAndNewlines), !extra.isEmpty { + prompt += "\n\n## Additional instructions\n\(extra)" + } + return prompt + } +} diff --git a/RxCode/Services/Hooks/hooks/CommitPushHook.swift b/RxCode/Services/Hooks/hooks/CommitPushHook.swift new file mode 100644 index 00000000..bbd8c8f0 --- /dev/null +++ b/RxCode/Services/Hooks/hooks/CommitPushHook.swift @@ -0,0 +1,90 @@ +import Foundation +import os +import RxCodeCore + +/// Built-in `.commitPush` hook. On a clean session stop, if the project has an +/// enabled Commit & Push hook, it sends a follow-up message into the same thread +/// instructing the agent to commit the changed files and push (the agent decides +/// new vs. existing branch). +/// +/// Loop prevention: the follow-up commit turn ends and re-enters this hook. The +/// hook marks the session via `markSetupSession(.commitPush)` before sending, and +/// short-circuits (consuming the marker) whenever it sees that mark — so the +/// commit turn never triggers another commit. It also runs only on clean +/// completions and, when a Code Review hook is also enabled, only after review +/// passed. +/// +/// Runs on `.afterSessionStop`. +@MainActor +final class CommitPushHook: Hook { + let hookID = "builtin.commitPush" + private let logger = Logger(subsystem: "com.claudework", category: "CommitPushHook") + + func afterSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { + let hooks = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) + .filter { $0.action == .commitPush } + guard let hook = hooks.first else { return .ignored } + + // Loop guard FIRST, so the commit turn always consumes its marker even if + // that turn errored — otherwise a stale marker would skip the next real + // turn's commit. + if controller.isSetupSession(kind: HookSetupKind.commitPush, sessionKey: payload.sessionKey) { + controller.clearSetupSession(kind: HookSetupKind.commitPush, sessionKey: payload.sessionKey) + logger.debug("[Hook] commit turn detected for session \(payload.sessionId, privacy: .public) — not re-triggering") + return .ignored + } + + guard payload.reason == .completed, !payload.turnDidError else { return .ignored } + + // Review gate: when a Code Review hook is also configured, only commit if + // the latest review passed. + let reviewConfigured = await controller.enabledHookProfiles(projectId: payload.project.id, trigger: .afterSessionStop) + .contains { $0.action == .codeReview } + if reviewConfigured { + let passed = controller.reviewPassed(sessionId: payload.sessionId) + ?? controller.reviewPassed(sessionId: payload.sessionKey) + guard passed == true else { + logger.debug("[Hook] skipping commit for session \(payload.sessionId, privacy: .public) — review not passed (\(String(describing: passed), privacy: .public))") + return .ignored + } + } + + let changedFiles = controller.changedFilePaths(sessionId: payload.sessionId) + guard !changedFiles.isEmpty else { return .ignored } + + // Mark BEFORE sending so the resulting commit turn short-circuits above. + controller.markSetupSession(kind: HookSetupKind.commitPush, sessionKey: payload.sessionKey) + + let card = controller.insertCard( + sessionKey: payload.sessionKey, + toolName: AppState.hookToolName(for: hook), + input: [ + "name": .string(hook.name), + "trigger": .string(hook.trigger.displayName), + "summary": .string("Asking the agent to commit & push the changes…"), + ] + ) + controller.completeCard(card, sessionKey: payload.sessionKey, result: "Requested commit & push of \(changedFiles.count) changed file(s).", isError: false) + + logger.debug("[Hook] requesting commit & push for session \(payload.sessionId, privacy: .public) files=\(changedFiles.count)") + controller.sendThreadMessage(sessionId: payload.sessionId, prompt: commitPrompt(changedFiles: changedFiles)) + return .proceed + } + + private func commitPrompt(changedFiles: [String]) -> String { + let fileList = changedFiles.map { "- \($0)" }.joined(separator: "\n") + return """ + Commit the changes from this session and push them to the remote. + + Files changed this session: + \(fileList) + + Steps: + 1. Stage the changed files and create a commit with a clear, conventional commit message describing the change. + 2. Choose an appropriate branch — reuse the current branch if suitable, or create a new branch if that is more appropriate — and push it to the remote (set upstream if needed). + 3. Report the branch name and the pushed commit. + + Do not make further code changes beyond what is needed to commit and push. + """ + } +} diff --git a/RxCode/Services/Hooks/hooks/UserAddedHook.swift b/RxCode/Services/Hooks/hooks/UserAddedHook.swift index 49ab3969..0b285cdd 100644 --- a/RxCode/Services/Hooks/hooks/UserAddedHook.swift +++ b/RxCode/Services/Hooks/hooks/UserAddedHook.swift @@ -34,7 +34,10 @@ final class UserAddedHook: Hook { sessionKey: String, controller: any HookController ) async -> HookOutcome { + // Only command hooks run here; built-in actions (code review, commit & + // push) are handled by their own dedicated hooks. let hooks = await controller.enabledHookProfiles(projectId: project.id, trigger: trigger) + .filter { $0.action == .command } guard !hooks.isEmpty else { return .ignored } var combined: [String] = [] diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 70aafcb5..751f284e 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -445,15 +445,7 @@ actor OpenAISummarizationService { } private func cleanSummary(_ raw: String?, limit: Int) -> String? { - guard let raw else { return nil } - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() let errorPrefixes = ["api error", "error:", "execution error", "request failed", "openai error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) + return GeneratedTextSanitizer.cleanSummary(raw, limit: limit, errorPrefixes: errorPrefixes) } } diff --git a/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index 7e8ba34d..ac2ad9e6 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -587,6 +587,21 @@ final class ThreadStore { save() } + /// Stamp linkage metadata (parent thread / label / skip-hooks) onto a thread + /// row. Used right after a linked `[Code Review]` thread's real id resolves. + func setThreadLinkage( + sessionId: String, + parentThreadId: String?, + threadLabel: String?, + skipHooks: Bool + ) { + guard let thread = fetch(id: sessionId) else { return } + thread.parentThreadId = parentThreadId + thread.threadLabel = threadLabel + thread.skipHooks = skipHooks + save() + } + private func renameHookStatus(from oldId: String, to newId: String) { guard oldId != newId else { return } guard let row = fetchHookStatus(sessionId: oldId) else { return } diff --git a/RxCode/Views/Hooks/HookConfigurationsView.swift b/RxCode/Views/Hooks/HookConfigurationsView.swift index 4f5db9a2..45623193 100644 --- a/RxCode/Views/Hooks/HookConfigurationsView.swift +++ b/RxCode/Views/Hooks/HookConfigurationsView.swift @@ -129,7 +129,7 @@ struct HookConfigurationsView: View { @ViewBuilder private func hookRow(_ hook: HookProfile) -> some View { HStack { - Image(systemName: "bolt.horizontal.circle") + Image(systemName: iconName(for: hook)) .foregroundStyle(hook.enabled ? ClaudeTheme.accent : ClaudeTheme.textTertiary) Text(hook.name.isEmpty ? "Untitled" : hook.name) .foregroundStyle(hook.enabled ? ClaudeTheme.textPrimary : ClaudeTheme.textTertiary) @@ -137,15 +137,43 @@ struct HookConfigurationsView: View { .tag(hook.id) } + /// SF Symbol per hook — built-in actions get their own glyph; command hooks + /// fall back to the per-type icon (matches `RunConfigurationsView`). + private func iconName(for hook: HookProfile) -> String { + switch hook.action { + case .codeReview: return "checkmark.seal.fill" + case .commitPush: return "arrow.up.circle.fill" + case .command: + switch hook.type { + case .xcode: return "hammer.fill" + case .make: return "wrench.and.screwdriver.fill" + case .bash: return "terminal" + case .packageScript: return "shippingbox.fill" + } + } + } + private var addMenu: some View { Menu { - ForEach(HookTrigger.allCases, id: \.self) { trigger in - Button { - addHook(trigger: trigger) - } label: { - Label(trigger.displayName, systemImage: "bolt.horizontal.circle") + Menu("Command") { + ForEach(HookTrigger.allCases, id: \.self) { trigger in + Button { + addHook(action: .command, trigger: trigger) + } label: { + Label(trigger.displayName, systemImage: "bolt.horizontal.circle") + } } } + Button { + addHook(action: .codeReview, trigger: HookAction.codeReview.fixedTrigger ?? .beforeSessionStop) + } label: { + Label("Code Review", systemImage: "checkmark.seal.fill") + } + Button { + addHook(action: .commitPush, trigger: HookAction.commitPush.fixedTrigger ?? .afterSessionStop) + } label: { + Label("Commit & Push", systemImage: "arrow.up.circle.fill") + } } label: { Image(systemName: "plus") } @@ -157,11 +185,19 @@ struct HookConfigurationsView: View { // MARK: - Actions - private func addHook(trigger: HookTrigger) { + private func addHook(action: HookAction, trigger: HookTrigger) { + let defaultName: String + switch action { + case .command: defaultName = "New Hook" + case .codeReview: defaultName = "Code Review" + case .commitPush: defaultName = "Commit & Push" + } let new = HookProfile( projectId: project.id, - name: "New Hook", - trigger: trigger + name: defaultName, + trigger: action.fixedTrigger ?? trigger, + action: action, + codeReview: action == .codeReview ? CodeReviewConfig() : nil ) draft.append(new) selectedId = new.id diff --git a/RxCode/Views/Hooks/HookProfileDetailForm.swift b/RxCode/Views/Hooks/HookProfileDetailForm.swift index 20c438b5..b91f9392 100644 --- a/RxCode/Views/Hooks/HookProfileDetailForm.swift +++ b/RxCode/Views/Hooks/HookProfileDetailForm.swift @@ -6,6 +6,7 @@ import SwiftUI /// bash-only and adds an enabled toggle + trigger picker. Reuses /// `BashEnvironmentEditor` for environment presets. struct HookProfileDetailForm: View { + @Environment(AppState.self) private var appState @Binding var hook: HookProfile let project: Project @@ -14,11 +15,24 @@ struct HookProfileDetailForm: View { Section { TextField("Name", text: $hook.name) Toggle("Enabled", isOn: $hook.enabled) + Picker("Action", selection: $hook.action) { + ForEach(HookAction.allCases, id: \.self) { action in + Text(action.displayName).tag(action) + } + } + .onChange(of: hook.action) { _, newAction in + // Built-in actions only run at a fixed lifecycle. + if let fixed = newAction.fixedTrigger { hook.trigger = fixed } + if newAction == .codeReview, hook.codeReview == nil { + hook.codeReview = CodeReviewConfig() + } + } Picker("Trigger", selection: $hook.trigger) { ForEach(HookTrigger.allCases, id: \.self) { trigger in Text(trigger.displayName).tag(trigger) } } + .disabled(hook.action.fixedTrigger != nil) } header: { Text("Configuration") } footer: { @@ -33,69 +47,106 @@ struct HookProfileDetailForm: View { Text("Lifecycle") } - Section { - TextEditor(text: $hook.bash.command) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) - ) - HStack { - TextField("Working Directory", text: $hook.bash.workingDirectory, prompt: Text(project.path)) - Button("Browse…") { - pickDirectory { picked in - hook.bash.workingDirectory = picked + switch hook.action { + case .command: + ProfileCommandSections( + profileID: hook.id, + project: project, + type: $hook.type, + bash: $hook.bash, + xcode: $hook.xcode, + make: $hook.make, + package: $hook.package + ) + BashEnvironmentEditor(bash: $hook.bash, projectPath: project.path) + case .codeReview: + codeReviewSection + case .commitPush: + commitPushSection + } + } + .formStyle(.grouped) + } + + // MARK: - Built-in action sections + + private var codeReviewSection: some View { + Section { + Picker("Review model", selection: codeReviewModelBinding) { + Text("Same as thread").tag("") + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section(section.title) { + ForEach(section.models, id: \.id) { model in + Text(model.displayName).tag(model.id) } } - Button { - hook.bash.workingDirectory = "" - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Reset to project root") } - } header: { - Text("Command") - } footer: { - Text("Runs with `/bin/zsh -lc`. Absolute or project-relative working directory; leave empty to use the project root.") } - - BashEnvironmentEditor(bash: $hook.bash, projectPath: project.path) + VStack(alignment: .leading, spacing: 4) { + Text("Extra instructions (optional)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + TextEditor(text: codeReviewInstructionsBinding) + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: 70) + } + } header: { + Text("Code Review") + } footer: { + Text("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).") } - .formStyle(.grouped) } - private var triggerHelp: String { - switch hook.trigger { - case .beforeSessionStart: - return "Runs once when a new thread starts. Its output is added to the agent's context for that turn." - case .beforeSessionStop: - return "Runs when streaming stops. Its output is shown and saved with the thread. If the command exits non-zero, its output is sent back to the agent to continue (up to 3 times)." - case .afterSessionStop: - return "Runs after streaming stops. Its output is shown only — nothing is passed back to the session." + private var commitPushSection: some View { + Section { + Text("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.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } header: { + Text("Commit & Push") } } - // MARK: - Directory picker - - private func pickDirectory(onPick: @escaping (String) -> Void) { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: project.path) - guard panel.runModal() == .OK, let url = panel.url else { return } - let root = (project.path as NSString).standardizingPath - let std = (url.path as NSString).standardizingPath - if std.hasPrefix(root + "/") { - onPick(String(std.dropFirst(root.count + 1))) - } else if std == root { - onPick("") - } else { - onPick(std) + private var codeReviewModelBinding: Binding { + Binding( + get: { hook.codeReview?.model ?? "" }, + set: { newValue in + var cfg = hook.codeReview ?? CodeReviewConfig() + cfg.model = newValue.isEmpty ? nil : newValue + hook.codeReview = cfg + } + ) + } + + private var codeReviewInstructionsBinding: Binding { + Binding( + get: { hook.codeReview?.instructions ?? "" }, + set: { newValue in + var cfg = hook.codeReview ?? CodeReviewConfig() + cfg.instructions = newValue.isEmpty ? nil : newValue + hook.codeReview = cfg + } + ) + } + + private var triggerHelp: String { + switch hook.action { + case .codeReview: + return "Code Review runs after the session is finalized; a failing review sends the agent back to work and re-reviews." + case .commitPush: + return "Commit & Push runs after the session is finalized and saved." + case .command: + switch hook.trigger { + case .beforeSessionStart: + return "Runs once when a new thread starts. Its output is added to the agent's context for that turn." + case .beforeSessionStop: + return "Runs when streaming stops. Its output is shown and saved with the thread. If the command exits non-zero, its output is sent back to the agent to continue (up to 3 times)." + case .afterSessionStop: + return "Runs after streaming stops. Its output is shown only — nothing is passed back to the session." + } } } + } // MARK: - Lifecycle diagram diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index d127b8e6..f1e23d2a 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -112,6 +112,27 @@ struct MainView: View { } } .hookUI() + .task(id: appState.isInitialized) { + guard appState.isInitialized else { return } + if appState.wasOnboardedAtLaunch { + // Existing user: surface any feature cards they haven't seen. + let unseen = appState.unseenWhatsNewFeatures + if !unseen.isEmpty { + appState.whatsNewBatch = unseen + appState.showWhatsNewSheet = true + } + } else { + // Brand-new install this session — these features aren't + // "new" to them, so record them all as seen up front. + appState.markAllWhatsNewSeen() + } + } + .sheet(isPresented: Bindable(appState).showWhatsNewSheet) { + WhatsNewSheet(features: appState.whatsNewBatch) { + appState.showWhatsNewSheet = false + } + .environment(appState) + } } } @@ -196,18 +217,19 @@ struct MainView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { if columnVisibility != .detailOnly { - ToolbarItem(placement: .navigation) { - Button { - showGitHubSheet = true - } label: { - Image(systemName: "square.and.arrow.down") - } - .help(appState.isSignedIn ? "Import Repository" : "Sign in to rxlab") - } + ToolbarItemGroup(placement: .navigation) { + Menu { + Button { + showFilePicker = true + } label: { + Label("Add project locally", systemImage: "folder.badge.plus") + } - ToolbarItem(placement: .navigation) { - Button { - showFilePicker = true + Button { + showGitHubSheet = true + } label: { + Label("Add project from remote repositories", systemImage: "square.and.arrow.down") + } } label: { Image(systemName: "plus") } @@ -219,9 +241,7 @@ struct MainView: View { ) { result in handleFolderSelection(result) } - } - ToolbarItem(placement: .navigation) { Button { windowState.showGlobalSearch = true } label: { @@ -229,9 +249,7 @@ struct MainView: View { } .help(String(localized: "Search Threads and Docs (⌘K)")) .popoverTip(RxCodeTips.GlobalSearchTip(), arrowEdge: .top) - } - ToolbarItem(placement: .navigation) { ThreadTitlePopoverButton(title: navigationTitleText) } } diff --git a/RxCode/Views/RunProfile/ProfileCommandSections.swift b/RxCode/Views/RunProfile/ProfileCommandSections.swift new file mode 100644 index 00000000..bda4b33f --- /dev/null +++ b/RxCode/Views/RunProfile/ProfileCommandSections.swift @@ -0,0 +1,493 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +/// Shared command-configuration form sections — the Type picker plus the +/// per-type command editors (bash / xcode / make / package) with their +/// auto-detection. Reused by both `RunProfileDetailForm` and +/// `HookProfileDetailForm` so the typed editors live in exactly one place. +/// +/// Operates on bindings to the individual config structs rather than a whole +/// `RunProfile`/`HookProfile`, so it is agnostic to which model it edits. +struct ProfileCommandSections: View { + /// Used only to scope detection cache keys to the edited profile/hook. + let profileID: UUID + let project: Project + @Binding var type: RunProfileType + @Binding var bash: BashRunConfig + @Binding var xcode: XcodeRunConfig? + @Binding var make: MakeRunConfig? + @Binding var package: PackageRunConfig? + + @State private var detectedMakeTargets: [String] = [] + @State private var detectedPackageScripts: [String] = [] + @State private var installedPackageManagers: [PackageManager] = [] + static let customTargetSentinel = "__rxcode_custom_target__" + static let customScriptSentinel = "__rxcode_custom_script__" + + var body: some View { + Group { + typeSection + + switch type { + case .bash: + bashCommandSection + case .xcode: + xcodeCommandSection + case .make: + makeCommandSection + case .packageScript: + packageCommandSection + } + } + } + + // MARK: - Type + + @ViewBuilder + private var typeSection: some View { + Section { + Picker("Type", selection: Binding( + get: { type }, + set: { newValue in + type = newValue + if newValue == .xcode, xcode == nil { + xcode = XcodeRunConfig() + } + if newValue == .make, make == nil { + make = MakeRunConfig() + } + if newValue == .packageScript, package == nil { + package = PackageRunConfig() + } + } + )) { + ForEach(RunProfileType.allCases, id: \.self) { t in + Text(t.displayName).tag(t) + } + } + } header: { + Text("Type") + } + } + + // MARK: - Command sections + + @ViewBuilder + var bashCommandSection: some View { + Section { + TextEditor(text: $bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Command") + } footer: { + Text("Absolute or project-relative path. Leave empty to use the project root.") + } + } + + @ViewBuilder + var xcodeCommandSection: some View { + Section { + let xcode = Binding( + get: { self.xcode ?? XcodeRunConfig() }, + set: { self.xcode = $0 } + ) + HStack { + TextField( + "Project / Workspace", + text: xcode.container, + prompt: Text("RxCode.xcodeproj") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickXcodeContainer { name, isWorkspace in + var c = xcode.wrappedValue + c.container = name + c.isWorkspace = isWorkspace + xcode.wrappedValue = c + } + } + } + Toggle("Use Workspace (.xcworkspace)", isOn: xcode.isWorkspace) + TextField("Scheme", text: xcode.scheme, prompt: Text("RxCode")) + .font(.system(.body, design: .monospaced)) + TextField("Configuration", text: xcode.configuration, prompt: Text("Debug")) + .font(.system(.body, design: .monospaced)) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + LabeledContent("Destination") { + HStack(spacing: 6) { + Text(xcode.wrappedValue.selectedDestination?.displayName ?? "Any Mac (default)") + .foregroundStyle(.secondary) + if xcode.wrappedValue.selectedDestination != nil { + Button { + var c = xcode.wrappedValue + c.selectedDestination = nil + xcode.wrappedValue = c + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("Clear destination — pick again from the toolbar") + } + } + } + } header: { + Text("Xcode") + } footer: { + Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar.") + } + } + + @ViewBuilder + var makeCommandSection: some View { + Section { + let make = Binding( + get: { self.make ?? MakeRunConfig() }, + set: { self.make = $0 } + ) + HStack { + TextField( + "Makefile (optional)", + text: make.makefile, + prompt: Text("Makefile") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickFile { picked in + var c = make.wrappedValue + c.makefile = picked + make.wrappedValue = c + } + } + Button { + var c = make.wrappedValue + c.makefile = "" + make.wrappedValue = c + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Use default Makefile lookup") + } + targetField(make: make) + TextField( + "Arguments (optional)", + text: make.arguments, + prompt: Text("VAR=value -j8") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Make") + } footer: { + Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") + } + .task(id: makeDetectionKey) { + detectedMakeTargets = await refreshMakeTargets() + } + } + + /// Cache key for re-parsing the Makefile when its path or the working + /// directory changes. + var makeDetectionKey: String { + "\(profileID.uuidString)|\(make?.makefile ?? "")|\(bash.workingDirectory)" + } + + func refreshMakeTargets() async -> [String] { + let projectRoot = project.path + let makefile = make?.makefile ?? "" + let workingDirRaw = bash.workingDirectory + return await Task.detached { () -> [String] in + let fm = FileManager.default + func resolve(_ p: String, against base: String) -> String { + if p.isEmpty { return base } + if p.hasPrefix("/") { return p } + return (base as NSString).appendingPathComponent(p) + } + let workingDir = workingDirRaw.isEmpty + ? projectRoot + : resolve(workingDirRaw, against: projectRoot) + + if !makefile.isEmpty { + // Picker produces project-relative paths; the executor resolves + // relative to working dir. Try both so the dropdown works + // regardless of which the user typed. + let candidates = [ + resolve(makefile, against: projectRoot), + resolve(makefile, against: workingDir), + ] + for path in candidates where fm.fileExists(atPath: path) { + return RunProfileDetector.makeTargets(atPath: path) + } + return [] + } + if let result = RunProfileDetector.defaultMakeTargets(inDirectory: workingDir) { + return result.targets + } + return RunProfileDetector.defaultMakeTargets(inDirectory: projectRoot)?.targets ?? [] + }.value + } + + @ViewBuilder + func targetField(make: Binding) -> some View { + let current = make.wrappedValue.target + let targets = detectedMakeTargets + let isCustom = !targets.isEmpty && !current.isEmpty && !targets.contains(current) + + if targets.isEmpty { + TextField("Target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } else { + Picker("Target", selection: Binding( + get: { + if current.isEmpty { return "" } + return isCustom ? Self.customTargetSentinel : current + }, + set: { newValue in + var c = make.wrappedValue + if newValue == Self.customTargetSentinel { + if targets.contains(c.target) { c.target = "" } + } else { + c.target = newValue + } + make.wrappedValue = c + } + )) { + if current.isEmpty { + Text("Select a target…").tag("") + } + ForEach(targets, id: \.self) { t in + Text(t).tag(t) + } + Divider() + Text("Custom…").tag(Self.customTargetSentinel) + } + .font(.system(.body, design: .monospaced)) + + if isCustom { + TextField("Custom target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } + } + } + + // MARK: - Package + + @ViewBuilder + var packageCommandSection: some View { + Section { + let pkg = Binding( + get: { self.package ?? PackageRunConfig() }, + set: { self.package = $0 } + ) + Picker("Package Manager", selection: pkg.packageManager) { + ForEach(PackageManager.allCases, id: \.self) { manager in + let installed = installedPackageManagers.isEmpty + || installedPackageManagers.contains(manager) + Text(installed ? manager.displayName : "\(manager.displayName) (not installed)") + .tag(manager) + .disabled(!installed) + } + } + scriptField(pkg: pkg) + TextField( + "Arguments (optional)", + text: pkg.arguments, + prompt: Text("-- --port 3000") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + bash.workingDirectory = picked + } + } + Button { + bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Node.js") + } footer: { + Text("Runs `\(packageCommandPreview)`. Arguments are appended after the script — use `-- ` to forward flags through npm/pnpm.") + } + .task(id: packageDetectionKey) { + detectedPackageScripts = await refreshPackageScripts() + installedPackageManagers = await RunProfileDetector.detectInstalledPackageManagers() + } + } + + /// ` run