From 11ac9caffb889c1da021e6746f44d099d9c3b258 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 1 May 2026 16:05:45 -0500 Subject: [PATCH 1/2] Add quick action buttons to session detail header Maps each PR status badge to a button that injects the next-step prompt (Rebase & Fix Conflicts, Address Review, Fix Checks, Merge PR) into the session's managed Claude Code terminal so the user can act in one click without switching focus. Reuses the auto-respond dispatch path; the addressChanges and fixChecks prompts share text with AutoRespondPrompts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CrowCore/Sources/CrowCore/AppState.swift | 14 +++ .../Sources/CrowCore/Models/QuickAction.swift | 20 ++++ .../Sources/CrowUI/SessionDetailView.swift | 73 ++++++++++++++ Sources/Crow/App/AppDelegate.swift | 5 + Sources/Crow/App/AutoRespondCoordinator.swift | 98 +++++++++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 Packages/CrowCore/Sources/CrowCore/Models/QuickAction.swift diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index ac94878..fdafa66 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -250,6 +250,12 @@ public final class AppState { /// Called to open a terminal at a session's primary worktree path. public var onOpenTerminal: ((UUID) -> Void)? + /// Called when the user clicks a quick action button on a session card + /// (e.g. "Merge PR", "Rebase & Fix Conflicts"). Receives the session ID + /// and the action chosen; the wired handler injects the corresponding + /// prompt into the session's managed Claude Code terminal. + public var onQuickAction: ((UUID, QuickAction) -> Void)? + /// Called when the sound mute toggle is changed. public var onSoundMutedChanged: ((Bool) -> Void)? @@ -291,6 +297,14 @@ public final class AppState { terminals[sessionID] ?? [] } + /// Whether a session has a managed Claude Code terminal that quick + /// actions can be dispatched into. The dispatcher in AppDelegate + /// re-checks the surface state before sending; this is the lighter + /// gate the view uses to decide whether to enable the buttons. + public func canDispatchQuickAction(sessionID: UUID) -> Bool { + terminals(for: sessionID).contains(where: { $0.isManaged }) + } + public func primaryWorktree(for sessionID: UUID) -> SessionWorktree? { worktrees[sessionID]?.first(where: { $0.isPrimary }) ?? worktrees[sessionID]?.first } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/QuickAction.swift b/Packages/CrowCore/Sources/CrowCore/Models/QuickAction.swift new file mode 100644 index 0000000..1aad04b --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Models/QuickAction.swift @@ -0,0 +1,20 @@ +import Foundation + +/// A user-triggered next step that maps 1:1 to a PR status badge on the +/// session card. Selecting the action injects a deterministic prompt into +/// the session's managed Claude Code terminal so the user can act without +/// switching focus into the session. +/// +/// Mirrors the auto-respond pipeline (`AutoRespondCoordinator`) but bypasses +/// the per-toggle `AutoRespondSettings` gate — the user clicked, so intent +/// is explicit. +public enum QuickAction: String, Sendable, Equatable { + /// `mergeable == .conflicting` — rebase onto base and resolve conflicts. + case fixConflicts + /// `reviewStatus == .changesRequested` — read review feedback and fix. + case addressChanges + /// `checksPass == .failing` — investigate failing checks and fix. + case fixChecks + /// `isReadyToMerge` — merge the PR. + case mergePR +} diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index 9cb202e..b280fe0 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -120,6 +120,8 @@ public struct SessionDetailView: View { // Action buttons (not for Manager) if session.id != AppState.managerSessionID { + quickActionButtons + if appState.vsCodeAvailable, primaryWorktree != nil { Button { appState.onOpenInVSCode?(session.id) @@ -218,6 +220,77 @@ public struct SessionDetailView: View { BrandmarkImage() } + // MARK: - Quick Action Buttons + + /// PR-status-aware buttons that inject the appropriate next-step prompt + /// into the session's managed Claude Code terminal. Each button maps + /// 1:1 to a status badge so the affordance is obvious. Hidden when the + /// PR is merged or no status is known; disabled when the session has + /// no managed terminal. + @ViewBuilder + private var quickActionButtons: some View { + if let status = appState.prStatus[session.id], !status.isMerged { + let canDispatch = appState.canDispatchQuickAction(sessionID: session.id) + let disabledHelp = "No managed Claude Code terminal in this session" + + if status.mergeable == .conflicting { + Button { + appState.onQuickAction?(session.id, .fixConflicts) + } label: { + Label("Rebase & Fix Conflicts", systemImage: "arrow.triangle.merge") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .disabled(!canDispatch) + .help(canDispatch ? "Send a rebase + resolve-conflicts prompt to the session terminal" : disabledHelp) + } + + if status.reviewStatus == .changesRequested { + Button { + appState.onQuickAction?(session.id, .addressChanges) + } label: { + Label("Address Review", systemImage: "pencil.and.list.clipboard") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .disabled(!canDispatch) + .help(canDispatch ? "Send a 'read review feedback and fix' prompt to the session terminal" : disabledHelp) + } + + if status.checksPass == .failing { + Button { + appState.onQuickAction?(session.id, .fixChecks) + } label: { + Label("Fix Checks", systemImage: "exclamationmark.triangle") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .disabled(!canDispatch) + .help(canDispatch ? "Send a 'fix failing CI checks' prompt to the session terminal" : disabledHelp) + } + + if status.isReadyToMerge { + Button { + appState.onQuickAction?(session.id, .mergePR) + } label: { + Label("Merge PR", systemImage: "arrow.triangle.pull") + .font(.caption) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(.green) + .disabled(!canDispatch) + .help(canDispatch ? "Send a 'merge this PR' prompt to the session terminal" : disabledHelp) + } + } + } + // MARK: - Terminal Area @ViewBuilder diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index d4551a2..dd82936 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -424,6 +424,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } ) + // Wire session-card quick action buttons through the same coordinator. + appState.onQuickAction = { [weak self] sessionID, action in + self?.autoRespondCoordinator?.dispatchManual(action: action, sessionID: sessionID) + } + // Initialize allow list service let allowList = AllowListService(appState: appState, devRoot: devRoot) self.allowListService = allowList diff --git a/Sources/Crow/App/AutoRespondCoordinator.swift b/Sources/Crow/App/AutoRespondCoordinator.swift index 5f9f363..2c1df2e 100644 --- a/Sources/Crow/App/AutoRespondCoordinator.swift +++ b/Sources/Crow/App/AutoRespondCoordinator.swift @@ -59,6 +59,42 @@ final class AutoRespondCoordinator { transition.kind.rawValue, terminal.id.uuidString, prompt.count) TerminalManager.shared.send(id: terminal.id, text: prompt) } + + /// Manually dispatch a quick action triggered by a session-card button click. + /// Mirrors `dispatch(_:)` but bypasses the `AutoRespondSettings` toggle — + /// the click is the user's explicit consent. Resolves the PR URL/number + /// from the session's `.pr` link. + func dispatchManual(action: QuickAction, sessionID: UUID) { + let terminals = appState.terminals(for: sessionID) + guard let terminal = terminals.first(where: { $0.isManaged }) else { + NSLog("[QuickAction] Skipping %@ for session %@: no managed terminal", + action.rawValue, sessionID.uuidString) + return + } + guard TerminalManager.shared.existingSurface(for: terminal.id) != nil else { + NSLog("[QuickAction] Skipping %@ for session %@: terminal surface not initialized", + action.rawValue, sessionID.uuidString) + return + } + guard let prLink = appState.links(for: sessionID).first(where: { $0.linkType == .pr }) else { + NSLog("[QuickAction] Skipping %@ for session %@: no PR link", + action.rawValue, sessionID.uuidString) + return + } + + let session = appState.sessions.first(where: { $0.id == sessionID }) + let provider = session?.provider ?? .github + let prNumber = QuickActionPrompts.parsePRNumber(from: prLink.url) + let prompt = QuickActionPrompts.build( + action: action, + provider: provider, + prURL: prLink.url, + prNumber: prNumber + ) + NSLog("[QuickAction] Sending %@ prompt to terminal %@ (%d chars)", + action.rawValue, terminal.id.uuidString, prompt.count) + TerminalManager.shared.send(id: terminal.id, text: prompt) + } } /// Builds the deterministic prompt text injected into a session's managed @@ -107,3 +143,65 @@ enum AutoRespondPrompts { } } } + +/// Builds prompts for **manually-triggered** quick actions on a session +/// card. Same single-line + `\n` contract as `AutoRespondPrompts`. The +/// `addressChanges` and `fixChecks` cases delegate to `AutoRespondPrompts` +/// so the auto and manual paths share a single source of truth. +enum QuickActionPrompts { + static func build(action: QuickAction, provider: Provider, prURL: String, prNumber: Int?) -> String { + let prRef = prNumber.map { "PR #\($0)" } ?? "the PR" + let cli = provider == .gitlab ? "glab" : "gh" + + switch action { + case .addressChanges: + // Reuse the existing changes-requested prompt verbatim. + let synthetic = PRStatusTransition( + kind: .changesRequested, + sessionID: UUID(), // unused by AutoRespondPrompts.build + prURL: prURL, + prNumber: prNumber + ) + return AutoRespondPrompts.build(for: synthetic, provider: provider) + + case .fixChecks: + // Reuse the existing checks-failing prompt verbatim. We don't + // know the failing check names from a manual click; the prompt + // tells Claude how to discover them. + let synthetic = PRStatusTransition( + kind: .checksFailing, + sessionID: UUID(), + prURL: prURL, + prNumber: prNumber + ) + return AutoRespondPrompts.build(for: synthetic, provider: provider) + + case .fixConflicts: + let rebaseHint: String + if provider == .gitlab { + rebaseHint = "Rebase your branch onto the latest target branch (`git fetch origin && git rebase origin/` or `glab mr rebase`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the MR." + } else { + rebaseHint = "Rebase your branch onto the latest base branch (`git fetch origin && git rebase origin/`), resolve the conflicts in the affected files, run the relevant tests, then force-push with `--force-with-lease` to update the PR." + } + return "Crow detected merge conflicts on \(prRef) (\(prURL)). \(rebaseHint)\n" + + case .mergePR: + let mergeHint: String + if provider == .gitlab { + mergeHint = "Run `glab mr view \(prURL)` to verify the MR is in the expected state, then `glab mr merge \(prURL)` to merge. If the project uses a different merge strategy or extra steps, adjust accordingly." + } else { + mergeHint = "Run `\(cli) pr view \(prURL)` to verify the PR is in the expected state, then `\(cli) pr merge \(prURL) --squash --delete-branch` to merge. If the repo uses a different merge strategy, adjust accordingly." + } + return "Merge \(prRef) (\(prURL)). \(mergeHint)\n" + } + } + + /// Extract the trailing numeric segment from a PR/MR URL (e.g. + /// `https://github.com/org/repo/pull/123` → `123`, + /// `https://gitlab.example.com/org/repo/-/merge_requests/45` → `45`). + /// Returns nil if the last path component isn't an integer. + static func parsePRNumber(from url: String) -> Int? { + guard let last = url.split(separator: "/").last else { return nil } + return Int(last) + } +} From e284f41d18190f7c9e5ec1ff77c5418e30a2916d Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Fri, 1 May 2026 16:42:49 -0500 Subject: [PATCH 2/2] Route quick actions through TerminalRouter for tmux backend Auto-respond and the new quick action dispatch were calling TerminalManager.shared.send and existingSurface(for:) directly. That silently fails for terminals on the tmux backend (CROW_TMUX_BACKEND=1) since the surface check returns nil for tmux-backed terminals. Switch both paths to TerminalRouter.send and add a backend-aware TerminalRouter.canSend gate (Ghostty: surface exists; tmux: window binding registered). Adds TmuxBackend.isRegistered(id:) to support it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/CrowTerminal/TmuxBackend.swift | 7 +++++++ Sources/Crow/App/AutoRespondCoordinator.swift | 8 ++++---- Sources/Crow/App/TerminalRouter.swift | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift index e3083ac..25560f7 100644 --- a/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift +++ b/Packages/CrowTerminal/Sources/CrowTerminal/TmuxBackend.swift @@ -288,6 +288,13 @@ public final class TmuxBackend { sharedSurface } + /// Whether `id` has a live tmux-window binding. Used by callers that + /// want to gate a send/destroy/makeActive on "this terminal is actually + /// wired up" without relying on the throwing dispatch path. + public func isRegistered(id: UUID) -> Bool { + bindings[id] != nil + } + // MARK: - Internal helpers private func ensureRunningServer() throws -> TmuxController { diff --git a/Sources/Crow/App/AutoRespondCoordinator.swift b/Sources/Crow/App/AutoRespondCoordinator.swift index 2c1df2e..9c919ed 100644 --- a/Sources/Crow/App/AutoRespondCoordinator.swift +++ b/Sources/Crow/App/AutoRespondCoordinator.swift @@ -47,7 +47,7 @@ final class AutoRespondCoordinator { transition.kind.rawValue, transition.sessionID.uuidString) return } - guard TerminalManager.shared.existingSurface(for: terminal.id) != nil else { + guard TerminalRouter.canSend(terminal) else { NSLog("[AutoRespond] Skipping %@ for session %@: terminal surface not initialized", transition.kind.rawValue, transition.sessionID.uuidString) return @@ -57,7 +57,7 @@ final class AutoRespondCoordinator { let prompt = AutoRespondPrompts.build(for: transition, provider: provider) NSLog("[AutoRespond] Sending %@ prompt to terminal %@ (%d chars)", transition.kind.rawValue, terminal.id.uuidString, prompt.count) - TerminalManager.shared.send(id: terminal.id, text: prompt) + TerminalRouter.send(terminal, text: prompt) } /// Manually dispatch a quick action triggered by a session-card button click. @@ -71,7 +71,7 @@ final class AutoRespondCoordinator { action.rawValue, sessionID.uuidString) return } - guard TerminalManager.shared.existingSurface(for: terminal.id) != nil else { + guard TerminalRouter.canSend(terminal) else { NSLog("[QuickAction] Skipping %@ for session %@: terminal surface not initialized", action.rawValue, sessionID.uuidString) return @@ -93,7 +93,7 @@ final class AutoRespondCoordinator { ) NSLog("[QuickAction] Sending %@ prompt to terminal %@ (%d chars)", action.rawValue, terminal.id.uuidString, prompt.count) - TerminalManager.shared.send(id: terminal.id, text: prompt) + TerminalRouter.send(terminal, text: prompt) } } diff --git a/Sources/Crow/App/TerminalRouter.swift b/Sources/Crow/App/TerminalRouter.swift index 6653fed..cded114 100644 --- a/Sources/Crow/App/TerminalRouter.swift +++ b/Sources/Crow/App/TerminalRouter.swift @@ -50,4 +50,18 @@ public enum TerminalRouter { break } } + + /// Whether the terminal's backing surface (Ghostty) or window + /// (tmux) is alive enough to receive a `send`. Callers that want to + /// fail-soft when the user hasn't materialized the terminal yet — e.g. + /// auto-respond and the session-card quick action buttons — gate on + /// this instead of relying on the underlying send to throw. + public static func canSend(_ terminal: SessionTerminal) -> Bool { + switch terminal.backend { + case .ghostty: + return TerminalManager.shared.existingSurface(for: terminal.id) != nil + case .tmux: + return TmuxBackend.shared.isRegistered(id: terminal.id) + } + } }