diff --git a/Packages/Sources/DiffView/DiffView.swift b/Packages/Sources/DiffView/DiffView.swift index 46377d8..6e44d1f 100644 --- a/Packages/Sources/DiffView/DiffView.swift +++ b/Packages/Sources/DiffView/DiffView.swift @@ -161,7 +161,12 @@ public struct DiffView: View { // scroll so line numbers stay anchored on the left. LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(lines.enumerated()), id: \.offset) { offset, line in - DiffRowGutter(line: line, layout: layout) + DiffRowGutter( + line: line, + layout: layout, + showsDiffMarkers: showsDiffMarkers, + fillsRowBackground: true + ) .id(rowID(for: offset)) } } @@ -177,6 +182,7 @@ public struct DiffView: View { line: line, layout: layout, showsDiffMarkers: showsDiffMarkers, + fillsRowBackground: true, wraps: false, language: language ) @@ -333,17 +339,24 @@ private struct DiffRow: View { var body: some View { HStack(alignment: .firstTextBaseline, spacing: 0) { - DiffRowGutter(line: line, layout: layout) + DiffRowGutter( + line: line, + layout: layout, + showsDiffMarkers: showsDiffMarkers, + fillsRowBackground: false + ) DiffRowBody( line: line, layout: layout, showsDiffMarkers: showsDiffMarkers, + fillsRowBackground: false, wraps: wraps, language: language ) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) + .background(line.diffRowBackground(showsDiffMarkers: showsDiffMarkers)) } } @@ -352,6 +365,8 @@ private struct DiffRow: View { private struct DiffRowGutter: View { let line: DiffLine let layout: GutterLayout + let showsDiffMarkers: Bool + let fillsRowBackground: Bool var body: some View { let isHeader = line.kind == .hunk || line.kind == .meta @@ -372,6 +387,11 @@ private struct DiffRowGutter: View { } } .frame(minHeight: DiffMetrics.rowMinHeight, alignment: .top) + .background( + fillsRowBackground + ? line.diffRowBackground(showsDiffMarkers: showsDiffMarkers) + : Color.clear + ) } } @@ -383,7 +403,7 @@ private struct DiffRowGutter: View { enum DiffMetrics { static var rowMinHeight: CGFloat { // ~lineHeight(12pt monospaced) + vertical padding(1 + 1). - ClaudeTheme.messageSize(12) * 1.35 + 2 + (ClaudeTheme.messageSize(12) * 1.35 + 2).rounded(.up) } } @@ -393,6 +413,7 @@ private struct DiffRowBody: View { let line: DiffLine let layout: GutterLayout let showsDiffMarkers: Bool + let fillsRowBackground: Bool let wraps: Bool /// File-extension hint (e.g. "swift", "ts") used to syntax-highlight the /// body text. `nil` skips highlighting and falls back to a flat color. @@ -414,7 +435,11 @@ private struct DiffRowBody: View { minHeight: DiffMetrics.rowMinHeight, alignment: .leading ) - .background(rowBackground) + .background( + fillsRowBackground + ? line.diffRowBackground(showsDiffMarkers: showsDiffMarkers) + : Color.clear + ) } @ViewBuilder @@ -493,11 +518,13 @@ private struct DiffRowBody: View { case .context: return ClaudeTheme.textPrimary } } +} - private var rowBackground: Color { +private extension DiffLine { + func diffRowBackground(showsDiffMarkers: Bool) -> Color { guard showsDiffMarkers else { return .clear } - switch line.kind { + switch kind { case .added: return ClaudeTheme.statusSuccess.opacity(0.12) case .removed: return ClaudeTheme.statusError.opacity(0.12) case .hunk: return ClaudeTheme.surfaceSecondary.opacity(0.6) diff --git a/Packages/Sources/RxCodeCore/Hooks/Hook.swift b/Packages/Sources/RxCodeCore/Hooks/Hook.swift index 3cf1788..d6e97ab 100644 --- a/Packages/Sources/RxCodeCore/Hooks/Hook.swift +++ b/Packages/Sources/RxCodeCore/Hooks/Hook.swift @@ -17,6 +17,8 @@ public protocol Hook: AnyObject { var isEnabled: Bool { get } func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome @@ -38,6 +40,8 @@ public extension Hook { var isEnabled: Bool { true } func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored } + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] } + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] } func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { .ignored } func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored } func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored } diff --git a/Packages/Sources/RxCodeCore/Hooks/HookController.swift b/Packages/Sources/RxCodeCore/Hooks/HookController.swift index 7a32741..b41e49c 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookController.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookController.swift @@ -151,6 +151,23 @@ public protocol HookController: AnyObject { /// system prompt and clears the pending flag. Returns `nil` otherwise. func consumePendingReleaseSetupSkill(projectId: UUID) -> String? + // MARK: Context menu actions + + /// Cached context-menu status for a project's linked repository. These are + /// intentionally synchronous because SwiftUI builds context menus + /// synchronously when the user opens them. + func projectHasSecrets(_ project: Project) -> Bool + func projectHasDocs(_ project: Project) -> Bool + func projectHasReleaseWorkflow(_ project: Project) -> Bool + + /// Centralized presentation actions used by hook-supplied context menus. + func requestSecretsSetup(project: Project) + func requestSecretsDownload(project: Project) + func requestDocsSetup(project: Project) + func requestDocsSearch(project: Project) + func requestReleaseSetup(project: Project) + func requestReleaseCreate(project: Project) + // MARK: Setup-session tracking /// Record that `sessionKey` belongs to a setup chat of the given `kind` (e.g. diff --git a/Packages/Sources/RxCodeCore/Hooks/HookMenuItem.swift b/Packages/Sources/RxCodeCore/Hooks/HookMenuItem.swift new file mode 100644 index 0000000..394319b --- /dev/null +++ b/Packages/Sources/RxCodeCore/Hooks/HookMenuItem.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftUI + +/// A single menu command supplied by a hook for a host-owned context menu. +/// +/// Context menus are built synchronously by SwiftUI, so hook menu items carry a +/// ready-to-run main-actor action instead of an async outcome. The host still +/// owns where the item is rendered and when standard menu sections are divided. +@MainActor +public struct HookMenuItem: Identifiable { + public let id: String + public let title: LocalizedStringResource + public let systemImage: String + public let role: ButtonRole? + public let action: @MainActor () -> Void + + public init( + id: String, + title: LocalizedStringResource, + systemImage: String, + role: ButtonRole? = nil, + action: @escaping @MainActor () -> Void + ) { + self.id = id + self.title = title + self.systemImage = systemImage + self.role = role + self.action = action + } +} diff --git a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift index 0ed0159..8f2bbd0 100644 --- a/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift +++ b/Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift @@ -6,6 +6,8 @@ import Foundation /// event to a future out-of-process plugin. public enum HookEventKind: String, Codable, Sendable, CaseIterable { case onProjectNewChatStart + case onThreadContextMenu + case onProjectContextMenu case onProjectDelete case onSessionStart case beforeSessionEnd @@ -48,6 +50,24 @@ public struct NewChatStartPayload: Codable, Sendable { } } +public struct ThreadContextMenuPayload: Codable, Sendable { + public let project: Project + public let session: ChatSession.Summary + + public init(project: Project, session: ChatSession.Summary) { + self.project = project + self.session = session + } +} + +public struct ProjectContextMenuPayload: Codable, Sendable { + public let project: Project + + public init(project: Project) { + self.project = project + } +} + public struct SessionStartPayload: Codable, Sendable { public let project: Project public let sessionKey: String diff --git a/Packages/Tests/DiffViewTests/GutterLayoutTests.swift b/Packages/Tests/DiffViewTests/GutterLayoutTests.swift index 06004ff..4470b36 100644 --- a/Packages/Tests/DiffViewTests/GutterLayoutTests.swift +++ b/Packages/Tests/DiffViewTests/GutterLayoutTests.swift @@ -82,4 +82,9 @@ struct GutterLayoutTests { #expect(DiffView.horizontalScrollBodyWidth(for: lines, layout: layout) > 320) } + + @Test("row height snaps to whole points") + func rowHeightUsesWholePoints() { + #expect(DiffMetrics.rowMinHeight == DiffMetrics.rowMinHeight.rounded(.down)) + } } diff --git a/RxCode/App/AppState+Docs.swift b/RxCode/App/AppState+Docs.swift index f257c9a..405ff49 100644 --- a/RxCode/App/AppState+Docs.swift +++ b/RxCode/App/AppState+Docs.swift @@ -31,5 +31,13 @@ extension AppState { func projectDocsState(_ repoFullName: String) -> Bool? { docsStatusByRepo[repoFullName.lowercased()]?.hasDocs } + + /// Whether the project's linked repo is registered with the docs service. + /// This mirrors the setup banner gate: once a repo is registered, setup is + /// considered complete even before the first CI-published document lands. + func projectHasDocs(_ project: Project) -> Bool { + guard let repo = project.gitHubRepo else { return false } + return docsStatusByRepo[repo.lowercased()]?.docsRepositoryId != nil + } } #endif diff --git a/RxCode/App/AppState+Hooks.swift b/RxCode/App/AppState+Hooks.swift index 25712d9..c3424de 100644 --- a/RxCode/App/AppState+Hooks.swift +++ b/RxCode/App/AppState+Hooks.swift @@ -44,6 +44,15 @@ extension AppState { ) } + func projectContextMenuItems(for project: Project) -> [HookMenuItem] { + hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project)) + } + + func threadContextMenuItems(for session: ChatSession.Summary) -> [HookMenuItem] { + guard let project = projects.first(where: { $0.id == session.projectId }) else { return [] } + return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session)) + } + // MARK: - Hook execution /// Tool-call name carried by a hook's chat card. The `Hook: ` prefix lets diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index 9abb39a..32e4a16 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -177,7 +177,7 @@ extension AppState { // 5-minute refresh timer, so no extra scheduling is needed here. await rxAuth.restore() if isSignedIn { - Task { [weak self] in await self?.loadRepos() } + startAutopilotWarmup() } // Periodically pull GitHub Actions CI status for open projects (no-ops @@ -433,6 +433,28 @@ extension AppState { window.isInitialized = true } + /// Starts the Autopilot-backed reads that power repo import, hook banners, + /// and briefing-card chips. This intentionally runs during app + /// initialization so the desktop loading screen can hide most of the network + /// latency instead of waiting for sidebar views to appear. + func startAutopilotWarmup() { + Task { [weak self] in + await self?.refreshAutopilotLaunchData() + } + } + + private func refreshAutopilotLaunchData() async { + guard isSignedIn else { return } + + async let repos: Void = loadRepos() + async let secrets: Void = refreshSecretsStatuses() + async let docs: Void = refreshDocsStatuses() + async let ciUpdates: Void = refreshCIStatuses() + async let releases: Void = refreshReleaseStatuses() + + _ = await (repos, secrets, docs, ciUpdates, releases) + } + func seedUITestBriefingIfRequested() { guard ProcessInfo.processInfo.environment["RXCODE_UI_TEST_SEED_BRIEFING"] == "1", let project = projects.first else { diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift index 7418ab2..25b18fb 100644 --- a/RxCode/App/AppState+Project.swift +++ b/RxCode/App/AppState+Project.swift @@ -251,7 +251,7 @@ extension AppState { func onRxAuthSignedIn() { onboardingCompleted = true UserDefaults.standard.set(true, forKey: "onboardingCompleted") - Task { await loadRepos() } + startAutopilotWarmup() } func signOutRxAuth() async { diff --git a/RxCode/App/AppState+PullRequest.swift b/RxCode/App/AppState+PullRequest.swift index abc0878..cbf71a3 100644 --- a/RxCode/App/AppState+PullRequest.swift +++ b/RxCode/App/AppState+PullRequest.swift @@ -156,6 +156,7 @@ extension AppState { title = ChatSession.stripMarkdownEmphasis(from: title) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) .trimmingCharacters(in: .whitespaces) + title = normalizePullRequestTitle(title, fallbackTitle: fallbackTitle) if title.isEmpty { title = fallbackTitle } let body = lines[(firstIdx + 1)...] @@ -163,4 +164,62 @@ extension AppState { .trimmingCharacters(in: .whitespacesAndNewlines) return (title, body) } + + private static func normalizePullRequestTitle(_ raw: String, fallbackTitle: String) -> String { + var title = stripTrailingPullRequestTitlePunctuation( + raw.trimmingCharacters(in: .whitespacesAndNewlines) + ) + guard !title.isEmpty else { return fallbackTitle } + + let pattern = #"^([A-Za-z]+)(\([^):\n]+\))?\s*:\s*(.+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: title, + range: NSRange(title.startIndex.. String { + var title = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let trailing = CharacterSet(charactersIn: ".!?。!?") + while let scalar = title.unicodeScalars.last, trailing.contains(scalar) { + title.removeLast() + title = title.trimmingCharacters(in: .whitespacesAndNewlines) + } + return title + } + + private static func lowercaseInitialPullRequestDescriptionWord(_ raw: String) -> String { + guard !raw.isEmpty else { return raw } + + var wordEnd = raw.startIndex + while wordEnd < raw.endIndex, raw[wordEnd].isLetter { + wordEnd = raw.index(after: wordEnd) + } + + guard wordEnd > raw.startIndex else { + guard let first = raw.first else { return raw } + return first.lowercased() + String(raw.dropFirst()) + } + + let word = String(raw[.. 1, word == word.uppercased() { + return raw + } + return word.lowercased() + String(raw[wordEnd...]) + } } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 6dfdb4f..0c0f807 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -939,6 +939,8 @@ final class AppState { /// Non-nil while the secret-setup form should be presented (e.g. opened from /// the autopilot `.env` banner's deep link). var secretsSetupRequest: SecretsSetupRequest? + /// Non-nil while the per-project secret download sheet should be presented. + var secretsDownloadRequest: Project? /// Non-nil while the CI auto-update manage sheet should be presented, pinned /// to a repo (opened from the CI setup banner's deep link). `MainView` /// consumes it to present the sheet pre-targeted at that repo. @@ -947,6 +949,9 @@ final class AppState { /// docs banner's deep link). `MainView` consumes it to start a fresh chat /// seeded with the docs-publishing skill. var docsSetupRequest: DocsSetupRequest? + /// Set when a hook/context-menu action wants to open the global docs-capable + /// search overlay in the current window. + var docsSearchRequest: UUID? /// One-shot: when a docs-setup chat is kicked off, this holds the project so /// `DocsHook.onSessionStart` injects the docs skill into exactly that chat's /// system prompt, then clears it. @@ -955,6 +960,8 @@ final class AppState { /// release banner's deep link). `MainView` consumes it to start a fresh chat /// seeded with the release skill. var releaseSetupRequest: ReleaseSetupRequest? + /// Non-nil while the create-release sheet should be presented. + var releaseCreateRequest: Project? /// One-shot: when a release-setup chat is kicked off, this holds the project /// so `ReleaseHook.onSessionStart` injects the release skill into exactly /// that chat's system prompt, then clears it. @@ -1188,9 +1195,9 @@ final class AppState { hookManager.register(CINotificationHook()) hookManager.register(RemoteConfigNotificationHook()) #if os(macOS) - hookManager.register(AutopilotHook()) - hookManager.register(DocsHook()) - hookManager.register(ReleaseHook()) + hookManager.register(AutopilotSecretsHook()) + hookManager.register(AutopilotDocsHook()) + hookManager.register(AutopilotReleaseHook()) hookManager.register(CIUpdateHook()) #endif } diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index f811344..b2953d0 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -12358,6 +12358,22 @@ } } }, + "Search Docs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "문서 검색" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索文档" + } + } + } + }, "Search documentation repositories" : { "localizations" : { "ko" : { @@ -12543,6 +12559,7 @@ } }, "Search Threads (⌘K)" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -12558,6 +12575,22 @@ } } }, + "Search Threads and Docs (⌘K)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스레드 및 문서 검색 (⌘K)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索对话和文档(⌘K)" + } + } + } + }, "Search threads and docs…" : { "localizations" : { "ko" : { @@ -13027,6 +13060,22 @@ } } }, + "Set Up Release Workflow" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴리스 워크플로 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置发布工作流" + } + } + } + }, "Set up releases for this repository" : { "localizations" : { "ko" : { @@ -13043,6 +13092,22 @@ } } }, + "Set Up Secrets" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시크릿 설정" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置密钥" + } + } + } + }, "Set up your first MCP server" : { "localizations" : { "ko" : { diff --git a/RxCode/Services/Hooks/AppStateHookController.swift b/RxCode/Services/Hooks/AppStateHookController.swift index 41c4d54..e4941b9 100644 --- a/RxCode/Services/Hooks/AppStateHookController.swift +++ b/RxCode/Services/Hooks/AppStateHookController.swift @@ -340,6 +340,48 @@ final class AppStateHookController: HookController { return ReleaseSkill.systemPrompt } + // MARK: Context menu actions + + func projectHasSecrets(_ project: Project) -> Bool { + app?.projectHasSecrets(project) ?? false + } + + func projectHasDocs(_ project: Project) -> Bool { + app?.projectHasDocs(project) ?? false + } + + func projectHasReleaseWorkflow(_ project: Project) -> Bool { + app?.projectHasReleaseWorkflow(project) ?? false + } + + func requestSecretsSetup(project: Project) { + app?.secretsSetupRequest = SecretsSetupRequest( + repoFullName: project.gitHubRepo, + projectPath: project.path, + filename: nil + ) + } + + func requestSecretsDownload(project: Project) { + app?.secretsDownloadRequest = project + } + + func requestDocsSetup(project: Project) { + app?.docsSetupRequest = DocsSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + } + + func requestDocsSearch(project: Project) { + app?.docsSearchRequest = UUID() + } + + func requestReleaseSetup(project: Project) { + app?.releaseSetupRequest = ReleaseSetupRequest(projectId: project.id, repoFullName: project.gitHubRepo) + } + + func requestReleaseCreate(project: Project) { + app?.releaseCreateRequest = project + } + // MARK: Setup-session tracking func markSetupSession(kind: String, sessionKey: String) { diff --git a/RxCode/Services/Hooks/HookManager.swift b/RxCode/Services/Hooks/HookManager.swift index 0dc54c5..a9c0168 100644 --- a/RxCode/Services/Hooks/HookManager.swift +++ b/RxCode/Services/Hooks/HookManager.swift @@ -33,6 +33,14 @@ final class HookManager { } } + func projectContextMenuItems(_ payload: ProjectContextMenuPayload) -> [HookMenuItem] { + enabledHooks.flatMap { $0.onProjectContextMenu(payload, controller: controller) } + } + + func threadContextMenuItems(_ payload: ThreadContextMenuPayload) -> [HookMenuItem] { + enabledHooks.flatMap { $0.onThreadContextMenu(payload, controller: controller) } + } + func dispatchProjectDelete(_ payload: ProjectDeletePayload) async { logger.debug("[Hook] dispatchProjectDelete: projectId=\(payload.project.id.uuidString, privacy: .public)") for hook in enabledHooks { diff --git a/RxCode/Services/Hooks/hooks/DocsHook.swift b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift similarity index 83% rename from RxCode/Services/Hooks/hooks/DocsHook.swift rename to RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift index d32743a..1306c18 100644 --- a/RxCode/Services/Hooks/hooks/DocsHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotDocsHook.swift @@ -9,7 +9,7 @@ import SwiftUI /// user accepts — injects the docs-publishing skill into that chat's system /// prompt so the agent wires up CI doc uploads. Mirrors `AutopilotHook`. @MainActor -final class DocsHook: Hook { +final class AutopilotDocsHook: Hook { let hookID = "builtin.docs" private let logger = Logger(subsystem: "com.claudework", category: "DocsHook") @@ -21,6 +21,39 @@ final class DocsHook: Hook { return "\(repoSlug)-new-project-docs" } + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + guard project.gitHubRepo != nil else { return [] } + if controller.projectHasDocs(project) { + return [ + HookMenuItem( + id: "\(hookID).search.\(project.id.uuidString)", + title: "Search Docs", + systemImage: "books.vertical.fill" + ) { + controller.requestDocsSearch(project: project) + } + ] + } + + return [ + HookMenuItem( + id: "\(hookID).setup.\(project.id.uuidString)", + title: "Set Up Docs Search", + systemImage: "books.vertical.fill" + ) { + controller.requestDocsSetup(project: project) + } + ] + } + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { guard let bannerID = bannerID(for: payload.project) else { return .ignored } controller.clearBannerDismissal(id: bannerID) diff --git a/RxCode/Services/Hooks/hooks/ReleaseHook.swift b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift similarity index 84% rename from RxCode/Services/Hooks/hooks/ReleaseHook.swift rename to RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift index fd85855..be9271a 100644 --- a/RxCode/Services/Hooks/hooks/ReleaseHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotReleaseHook.swift @@ -9,7 +9,7 @@ import SwiftUI /// injects the release skill into that chat's system prompt so the agent wires /// up the `.releaserc` + CI workflow. Mirrors `DocsHook`. @MainActor -final class ReleaseHook: Hook { +final class AutopilotReleaseHook: Hook { let hookID = "builtin.release" private let logger = Logger(subsystem: "com.claudework", category: "ReleaseHook") @@ -21,6 +21,39 @@ final class ReleaseHook: Hook { return "\(repoSlug)-new-project-release" } + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + guard project.gitHubRepo != nil else { return [] } + if controller.projectHasReleaseWorkflow(project) { + return [ + HookMenuItem( + id: "\(hookID).create.\(project.id.uuidString)", + title: "Create Release", + systemImage: "tag.fill" + ) { + controller.requestReleaseCreate(project: project) + } + ] + } + + return [ + HookMenuItem( + id: "\(hookID).setup.\(project.id.uuidString)", + title: "Set Up Release Workflow", + systemImage: "tag.fill" + ) { + controller.requestReleaseSetup(project: project) + } + ] + } + func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { guard let bannerID = bannerID(for: payload.project) else { return .ignored } controller.clearBannerDismissal(id: bannerID) diff --git a/RxCode/Services/Hooks/hooks/AutopilotHook.swift b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift similarity index 88% rename from RxCode/Services/Hooks/hooks/AutopilotHook.swift rename to RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift index 8016caa..306b8e4 100644 --- a/RxCode/Services/Hooks/hooks/AutopilotHook.swift +++ b/RxCode/Services/Hooks/hooks/AutopilotSecretsHook.swift @@ -13,7 +13,7 @@ import SwiftUI /// - On new chat, surface a banner when the project's local `.env*` files /// aren't backed up to autopilot (see `onProjectNewChatStart`). @MainActor -final class AutopilotHook: Hook { +final class AutopilotSecretsHook: Hook { let hookID = "builtin.autopilot" private let logger = Logger(subsystem: "com.claudework", category: "AutopilotHook") @@ -21,6 +21,39 @@ final class AutopilotHook: Hook { await run(project: payload.project, controller: controller) } + func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { + menuItems(for: payload.project, controller: controller) + } + + private func menuItems(for project: Project, controller: any HookController) -> [HookMenuItem] { + guard project.gitHubRepo != nil else { return [] } + if controller.projectHasSecrets(project) { + return [ + HookMenuItem( + id: "\(hookID).download.\(project.id.uuidString)", + title: "Download Secret", + systemImage: "key.fill" + ) { + controller.requestSecretsDownload(project: project) + } + ] + } + + return [ + HookMenuItem( + id: "\(hookID).setup.\(project.id.uuidString)", + title: "Set Up Secrets", + systemImage: "key.fill" + ) { + controller.requestSecretsSetup(project: project) + } + ] + } + /// Stable, readable banner id for a repo, used both as the banner key and the /// persisted "dismissed" key, e.g. repo "owner/github-pm" → /// "github-pm-new-project-autopilot". Returns nil when the project has no repo. diff --git a/RxCode/Views/Chat/RecentChatsSuggestionList.swift b/RxCode/Views/Chat/RecentChatsSuggestionList.swift index 32561c7..cf99c01 100644 --- a/RxCode/Views/Chat/RecentChatsSuggestionList.swift +++ b/RxCode/Views/Chat/RecentChatsSuggestionList.swift @@ -141,6 +141,12 @@ struct RecentChatsSuggestionList: View { } } + let hookItems = appState.threadContextMenuItems(for: summary) + if !hookItems.isEmpty { + Divider() + HookContextMenuItems(items: hookItems) + } + Divider() Button(role: .destructive) { diff --git a/RxCode/Views/Docs/DocsDeepLink.swift b/RxCode/Views/Docs/DocsDeepLink.swift index c0e65e3..15ae369 100644 --- a/RxCode/Views/Docs/DocsDeepLink.swift +++ b/RxCode/Views/Docs/DocsDeepLink.swift @@ -4,7 +4,13 @@ import Foundation /// the docs-publishing skill so the agent wires up CI doc uploads. struct DocsSetupRequest: Identifiable, Hashable { let id = UUID() + var projectId: UUID? var repoFullName: String? + + init(projectId: UUID? = nil, repoFullName: String? = nil) { + self.projectId = projectId + self.repoFullName = repoFullName + } } /// Parses `rxcode://docs/setup?repo=` and diff --git a/RxCode/Views/Hooks/HookContextMenuItems.swift b/RxCode/Views/Hooks/HookContextMenuItems.swift new file mode 100644 index 0000000..f503202 --- /dev/null +++ b/RxCode/Views/Hooks/HookContextMenuItems.swift @@ -0,0 +1,20 @@ +import RxCodeCore +import SwiftUI + +struct HookContextMenuItems: View { + let items: [HookMenuItem] + + var body: some View { + ForEach(items) { item in + Button(role: item.role) { + item.action() + } label: { + Label { + Text(item.title) + } icon: { + Image(systemName: item.systemImage) + } + } + } + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 9deb9be..d127b8e 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -227,7 +227,7 @@ struct MainView: View { } label: { Image(systemName: "magnifyingglass") } - .help("Search Threads (⌘K)") + .help(String(localized: "Search Threads and Docs (⌘K)")) .popoverTip(RxCodeTips.GlobalSearchTip(), arrowEdge: .top) } @@ -303,6 +303,10 @@ struct MainView: View { ) .environment(appState) } + .sheet(item: Bindable(appState).secretsDownloadRequest) { project in + SecretsDownloadSheet(project: project) + .environment(appState) + } .sheet(item: Bindable(appState).ciSetupRequest, onDismiss: rerunNewChatHooks) { request in CIUpdateManageSheet( currentRepoFullName: request.repoFullName, @@ -310,11 +314,21 @@ struct MainView: View { ) .environment(appState) } + .onChange(of: appState.docsSearchRequest) { _, request in + guard request != nil else { return } + windowState.showGlobalSearch = true + appState.docsSearchRequest = nil + } .onChange(of: appState.docsSetupRequest?.id) { _, _ in guard let request = appState.docsSetupRequest else { return } // Start a fresh chat in this project; DocsHook.onSessionStart injects // the docs-publishing skill into its system prompt on first send. - appState.pendingDocsSetupProjectId = windowState.selectedProject?.id + if let projectId = request.projectId, + let project = appState.projects.first(where: { $0.id == projectId }), + windowState.selectedProject?.id != projectId { + appState.selectProject(project, in: windowState) + } + appState.pendingDocsSetupProjectId = request.projectId ?? windowState.selectedProject?.id appState.startNewChat(in: windowState) let repoText = request.repoFullName.map { " for \($0)" } ?? "" let prompt = "Set up documentation publishing\(repoText) by following the docs-publishing skill: inspect the repo, author the docs under docs/, add the uploader script and CI workflow, and tell me exactly what DOCS_UPLOAD_TOKEN to set." @@ -330,13 +344,27 @@ struct MainView: View { guard let request = appState.releaseSetupRequest else { return } // Start a fresh chat in this project; ReleaseHook.onSessionStart // injects the release skill into its system prompt on first send. - appState.pendingReleaseSetupProjectId = windowState.selectedProject?.id + if let projectId = request.projectId, + let project = appState.projects.first(where: { $0.id == projectId }), + windowState.selectedProject?.id != projectId { + appState.selectProject(project, in: windowState) + } + appState.pendingReleaseSetupProjectId = request.projectId ?? windowState.selectedProject?.id appState.startNewChat(in: windowState) let repoText = request.repoFullName.map { " for \($0)" } ?? "" let prompt = "Set up release publishing\(repoText) by following the create-release skill: inspect the repo, create the `.releaserc` and the release CI workflow (ask me whether to trigger releases on branch push or manually), then register the repo and install the RELEASE_TOKEN via the `ide__setup_release` tool." appState.releaseSetupRequest = nil Task { await appState.sendPrompt(prompt, in: windowState) } } + .sheet(item: Bindable(appState).releaseCreateRequest) { project in + ReleaseCreateSheet( + repoId: project.gitHubRepo ?? "", + repoFullName: project.gitHubRepo ?? project.name, + currentVersion: appState.projectLatestReleaseVersion(project), + projectPath: project.path + ) + .environment(appState) + } .sheet(item: Bindable(windowState).diffFile) { file in FileDiffView( filePath: file.path, @@ -452,6 +480,11 @@ struct ProjectTabButton: View { openWindow(id: "project-window", value: ProjectWindowValue(projectId: project.id, instanceId: UUID())) } .contextMenu { + let hookItems = appState.projectContextMenuItems(for: project) + if !hookItems.isEmpty { + HookContextMenuItems(items: hookItems) + Divider() + } Button { renameText = project.name projectToRename = project diff --git a/RxCode/Views/Release/ReleaseDeepLink.swift b/RxCode/Views/Release/ReleaseDeepLink.swift index a3837f6..efd6d3b 100644 --- a/RxCode/Views/Release/ReleaseDeepLink.swift +++ b/RxCode/Views/Release/ReleaseDeepLink.swift @@ -4,7 +4,13 @@ import Foundation /// the release skill so the agent wires up the `.releaserc` + CI workflow. struct ReleaseSetupRequest: Identifiable, Hashable { let id = UUID() + var projectId: UUID? var repoFullName: String? + + init(projectId: UUID? = nil, repoFullName: String? = nil) { + self.projectId = projectId + self.repoFullName = repoFullName + } } /// Parses `rxcode://release/setup?repo=` and diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index fb553b5..f21a4df 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -29,9 +29,6 @@ struct BriefingView: View { /// Container width tracked from the scroll content; drives the waterfall column count. @State private var availableWidth: CGFloat = 800 - /// Non-nil while the "Create Release" sheet is presented for a project. - @State private var createReleaseProject: Project? - /// Presents the account-level autopilot automation settings form. @State private var showAutomationSettings = false @@ -157,15 +154,6 @@ struct BriefingView: View { .onAppear { AnalyticsService.shared.log(.briefingListOpened) } - .sheet(item: $createReleaseProject) { project in - ReleaseCreateSheet( - repoId: project.gitHubRepo ?? "", - repoFullName: project.gitHubRepo ?? project.name, - currentVersion: appState.projectLatestReleaseVersion(project), - projectPath: project.path - ) - .environment(appState) - } .sheet(isPresented: $showAutomationSettings) { AutomationSettingsSheet() .environment(appState) @@ -658,13 +646,10 @@ struct BriefingView: View { Label("Open Project", systemImage: "folder") } - if appState.projectHasReleaseWorkflow(project) { + let hookItems = appState.projectContextMenuItems(for: project) + if !hookItems.isEmpty { Divider() - Button { - createReleaseProject = project - } label: { - Label("Create Release", systemImage: "tag.fill") - } + HookContextMenuItems(items: hookItems) } if let url = gitHubURL(for: group, project: project) { diff --git a/RxCode/Views/Sidebar/HistoryListView.swift b/RxCode/Views/Sidebar/HistoryListView.swift index ed05f7f..f71baf4 100644 --- a/RxCode/Views/Sidebar/HistoryListView.swift +++ b/RxCode/Views/Sidebar/HistoryListView.swift @@ -216,6 +216,12 @@ struct HistoryListView: View { } } + let hookItems = appState.threadContextMenuItems(for: summary) + if !hookItems.isEmpty { + Divider() + HookContextMenuItems(items: hookItems) + } + Divider() Button(role: .destructive) { diff --git a/RxCode/Views/Sidebar/ProjectChatRow.swift b/RxCode/Views/Sidebar/ProjectChatRow.swift index e6363aa..c1a0984 100644 --- a/RxCode/Views/Sidebar/ProjectChatRow.swift +++ b/RxCode/Views/Sidebar/ProjectChatRow.swift @@ -83,6 +83,7 @@ struct ProjectChatRow: View { let onTogglePin: () -> Void let onToggleArchive: () -> Void let onDelete: () -> Void + let hookMenuItems: [HookMenuItem] @State private var isHovered = false @@ -168,6 +169,10 @@ struct ProjectChatRow: View { Label("Archive", systemImage: "archivebox") } } + if !hookMenuItems.isEmpty { + Divider() + HookContextMenuItems(items: hookMenuItems) + } Divider() Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") diff --git a/RxCode/Views/Sidebar/ProjectListView.swift b/RxCode/Views/Sidebar/ProjectListView.swift index aabfce0..a04d6f0 100644 --- a/RxCode/Views/Sidebar/ProjectListView.swift +++ b/RxCode/Views/Sidebar/ProjectListView.swift @@ -20,6 +20,11 @@ struct ProjectListView: View { projectRow(project) .tag(project.id) .contextMenu { + let hookItems = appState.projectContextMenuItems(for: project) + if !hookItems.isEmpty { + HookContextMenuItems(items: hookItems) + Divider() + } Button { renameText = project.name projectToRename = project diff --git a/RxCode/Views/Sidebar/ProjectTreeView.swift b/RxCode/Views/Sidebar/ProjectTreeView.swift index ecdc3ef..61819c3 100644 --- a/RxCode/Views/Sidebar/ProjectTreeView.swift +++ b/RxCode/Views/Sidebar/ProjectTreeView.swift @@ -1,5 +1,5 @@ -import SwiftUI import RxCodeCore +import SwiftUI import TipKit // MARK: - ProjectTreeView @@ -11,7 +11,6 @@ struct ProjectTreeView: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState @Environment(\.openWindow) private var openWindow - @Environment(\.openURL) private var openURL @State private var expandedProjectIds: Set = [] @State private var renameProject: Project? = nil @@ -24,8 +23,6 @@ struct ProjectTreeView: View { @State private var archiveSession: ChatSession? = nil @State private var showAllChatsSheet = false - @State private var downloadSecretProject: Project? = nil - @State private var createReleaseProject: Project? = nil var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -128,19 +125,6 @@ struct ProjectTreeView: View { .sheet(isPresented: $showAllChatsSheet) { AllChatsHistorySheet(isPresented: $showAllChatsSheet) } - .sheet(item: $downloadSecretProject) { project in - SecretsDownloadSheet(project: project) - .environment(appState) - } - .sheet(item: $createReleaseProject) { project in - ReleaseCreateSheet( - repoId: project.gitHubRepo ?? "", - repoFullName: project.gitHubRepo ?? project.name, - currentVersion: appState.projectLatestReleaseVersion(project), - projectPath: project.path - ) - .environment(appState) - } .onChange(of: windowState.selectedProject?.id) { _, newId in if let newId { expandedProjectIds.insert(newId) } } @@ -151,6 +135,7 @@ struct ProjectTreeView: View { } .task(id: appState.projects.map { $0.gitHubRepo ?? "" }.joined(separator: ",")) { await appState.refreshSecretsStatuses() + await appState.refreshDocsStatuses() await appState.refreshReleaseStatuses() } } @@ -175,55 +160,55 @@ struct ProjectTreeView: View { } .buttonStyle(.borderless) .help("Show all chats") + } } -} -// MARK: - SummarySidebarSection + // MARK: - SummarySidebarSection -private struct SummarySidebarSection: View { - @Environment(WindowState.self) private var windowState + private struct SummarySidebarSection: View { + @Environment(WindowState.self) private var windowState - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text("General") - .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textTertiary) - .textCase(.uppercase) - .padding(.horizontal, 12) - .padding(.bottom, 2) + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("General") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .textCase(.uppercase) + .padding(.horizontal, 12) + .padding(.bottom, 2) - Button { - windowState.showingBriefing = true - } label: { - HStack(spacing: 8) { - Image(systemName: "text.page") - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - .frame(width: 18, height: 18) + Button { + windowState.showingBriefing = true + } label: { + HStack(spacing: 8) { + Image(systemName: "text.page") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + .frame(width: 18, height: 18) - Text("Briefing") - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .lineLimit(1) + Text("Briefing") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .lineLimit(1) - Spacer(minLength: 4) + Spacer(minLength: 4) + } + .foregroundStyle(windowState.showingBriefing ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(windowState.showingBriefing ? ClaudeTheme.accent.opacity(0.10) : Color.clear) + ) + .padding(.horizontal, 8) + .contentShape(Rectangle()) } - .foregroundStyle(windowState.showingBriefing ? ClaudeTheme.accent : ClaudeTheme.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(windowState.showingBriefing ? ClaudeTheme.accent.opacity(0.10) : Color.clear) - ) - .padding(.horizontal, 8) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .help("Open project branch briefing") + .popoverTip(RxCodeTips.BriefingTip(), arrowEdge: .trailing) } - .buttonStyle(.plain) - .help("Open project branch briefing") - .popoverTip(RxCodeTips.BriefingTip(), arrowEdge: .trailing) } } -} -// MARK: - Empty State + // MARK: - Empty State private var emptyState: some View { VStack(spacing: 8) { @@ -274,16 +259,7 @@ private struct SummarySidebarSection: View { appState.selectProject(project, in: windowState) appState.startNewChat(in: windowState) }, - onDownloadSecret: { downloadSecretProject = project }, - hasSecrets: appState.projectHasSecrets(project), - onSetupDocsSearch: { - guard let repo = project.gitHubRepo, - let url = DocsDeepLink.setupURL(repo: repo) else { return } - openURL(url) - }, - canSetupDocsSearch: project.gitHubRepo != nil, - hasReleaseWorkflow: appState.projectHasReleaseWorkflow(project), - onCreateRelease: { createReleaseProject = project } + hookMenuItems: appState.projectContextMenuItems(for: project) ) if expandedProjectIds.contains(project.id) { @@ -327,12 +303,7 @@ private struct ProjectTreeRow: View { let onRename: () -> Void let onDelete: () -> Void let onNewChat: () -> Void - let onDownloadSecret: () -> Void - let hasSecrets: Bool - let onSetupDocsSearch: () -> Void - let canSetupDocsSearch: Bool - let hasReleaseWorkflow: Bool - let onCreateRelease: () -> Void + let hookMenuItems: [HookMenuItem] @State private var isHovered = false @State private var showLocationPopover = false @@ -454,25 +425,11 @@ private struct ProjectTreeRow: View { Button { onOpenInNewWindow() } label: { Label("Open in New Window", systemImage: "macwindow.badge.plus") } - Divider() - if canSetupDocsSearch { - Button { onSetupDocsSearch() } label: { - Label("Set Up Docs Search", systemImage: "books.vertical.fill") - } - } - if hasSecrets { - Button { onDownloadSecret() } label: { - Label("Download Secret", systemImage: "key.fill") - } - } - if hasReleaseWorkflow { - Button { onCreateRelease() } label: { - Label("Create Release", systemImage: "tag.fill") - } - } - if canSetupDocsSearch || hasSecrets || hasReleaseWorkflow { + if !hookMenuItems.isEmpty { Divider() + HookContextMenuItems(items: hookMenuItems) } + Divider() Button { onRename() } label: { Label("Rename Project", systemImage: "pencil") } @@ -611,7 +568,8 @@ private struct ProjectChatsList: View { }, onDelete: { onDeleteSession(session) - } + }, + hookMenuItems: appState.threadContextMenuItems(for: summary) ) } } diff --git a/RxCodeTests/AppStateTests.swift b/RxCodeTests/AppStateTests.swift index 1b4ce87..8796812 100644 --- a/RxCodeTests/AppStateTests.swift +++ b/RxCodeTests/AppStateTests.swift @@ -514,6 +514,37 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(appState.memoryMaxContextItems, 7) } + // MARK: - Pull request content + + func testParsePullRequestContentNormalizesConventionalTitle() { + let raw = """ + Fix: Enhance parallel API calls and improve startup warmup and Autopilot API handling. + + Updates the branch behavior. + """ + + let result = AppState.parsePullRequestContent(raw, branch: "context-menu") + + XCTAssertEqual( + result.title, + "fix: enhance parallel API calls and improve startup warmup and Autopilot API handling" + ) + XCTAssertEqual(result.body, "Updates the branch behavior.") + } + + func testParsePullRequestContentNormalizesScopedConventionalTitle() { + let raw = """ + Title: Feat(Autopilot): Add docs search! + + Adds the docs search flow. + """ + + let result = AppState.parsePullRequestContent(raw, branch: "context-menu") + + XCTAssertEqual(result.title, "feat(autopilot): add docs search") + XCTAssertEqual(result.body, "Adds the docs search flow.") + } + // MARK: - Helpers private func makeProject(_ name: String) -> Project {