From 08da7ca22a81189ba42864ffc268234404cff22c Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Mon, 1 Jun 2026 17:19:51 +0800
Subject: [PATCH] feat: add context menu hook support
---
Packages/Sources/DiffView/DiffView.swift | 39 ++++-
Packages/Sources/RxCodeCore/Hooks/Hook.swift | 4 +
.../RxCodeCore/Hooks/HookController.swift | 17 +++
.../RxCodeCore/Hooks/HookMenuItem.swift | 30 ++++
.../RxCodeCore/Hooks/HookPayloads.swift | 20 +++
.../DiffViewTests/GutterLayoutTests.swift | 5 +
RxCode/App/AppState+Docs.swift | 8 ++
RxCode/App/AppState+Hooks.swift | 9 ++
RxCode/App/AppState+Lifecycle.swift | 24 +++-
RxCode/App/AppState+Project.swift | 2 +-
RxCode/App/AppState+PullRequest.swift | 59 ++++++++
RxCode/App/AppState.swift | 13 +-
RxCode/Resources/Localizable.xcstrings | 65 +++++++++
.../Hooks/AppStateHookController.swift | 42 ++++++
RxCode/Services/Hooks/HookManager.swift | 8 ++
...DocsHook.swift => AutopilotDocsHook.swift} | 35 ++++-
...eHook.swift => AutopilotReleaseHook.swift} | 35 ++++-
...tHook.swift => AutopilotSecretsHook.swift} | 35 ++++-
.../Chat/RecentChatsSuggestionList.swift | 6 +
RxCode/Views/Docs/DocsDeepLink.swift | 6 +
RxCode/Views/Hooks/HookContextMenuItems.swift | 20 +++
RxCode/Views/MainView.swift | 39 ++++-
RxCode/Views/Release/ReleaseDeepLink.swift | 6 +
RxCode/Views/Sidebar/BriefingView.swift | 21 +--
RxCode/Views/Sidebar/HistoryListView.swift | 6 +
RxCode/Views/Sidebar/ProjectChatRow.swift | 5 +
RxCode/Views/Sidebar/ProjectListView.swift | 5 +
RxCode/Views/Sidebar/ProjectTreeView.swift | 134 ++++++------------
RxCodeTests/AppStateTests.swift | 31 ++++
29 files changed, 606 insertions(+), 123 deletions(-)
create mode 100644 Packages/Sources/RxCodeCore/Hooks/HookMenuItem.swift
rename RxCode/Services/Hooks/hooks/{DocsHook.swift => AutopilotDocsHook.swift} (83%)
rename RxCode/Services/Hooks/hooks/{ReleaseHook.swift => AutopilotReleaseHook.swift} (84%)
rename RxCode/Services/Hooks/hooks/{AutopilotHook.swift => AutopilotSecretsHook.swift} (88%)
create mode 100644 RxCode/Views/Hooks/HookContextMenuItems.swift
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 {