From f3879e3d3e519bdc8339a03160e27ab5c57ff54c Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 15:04:37 +0200 Subject: [PATCH 1/6] feat(deck): copy a section's PRs as a Markdown list from its header --- CLAUDE.md | 5 +- Mainline.xcodeproj/project.pbxproj | 4 + Mainline/Mainline/MainlineApp.swift | 1 + Mainline/Mainline/Services/PRListExport.swift | 99 ++++++++++++++++ Mainline/Mainline/Views/TriageDeckView.swift | 108 +++++++++++++++--- 5 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 Mainline/Mainline/Services/PRListExport.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4e236e5..6f57082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ Mainline/Mainline/ ← Source root │ ├── SensitivePathMatcher.swift ← Pure path/branch-name heuristic classifier │ ├── TriageClassifier.swift ← Pure needsHuman predicate engine │ ├── PRSearchFilter.swift ← Pure search matcher (number / PR-URL / free-text); `matches(_:query:)` + `runSelfChecks()` #if DEBUG +│ ├── PRListExport.swift ← Pure Markdown formatter for a section's PRs (URL + CI/review/draft/conflict/threads/size/branch per PR), copied by the hover button on deck section headers; `PRListExportChecks.run()` #if DEBUG │ ├── InboxMuteEngine.swift ← Pure glob matcher + four mute-rule predicates (pattern/botAuthor/label/outsideFocus); InboxMuteConfig + per-org OrgFocusConfig value structs; muteVerdict takes the PR's `org`; runSelfChecks() #if DEBUG │ ├── ScopeStore.swift ← @MainActor derives org/repo scopes from PR list; drives badge │ ├── StackEngine.swift ← Pure stacked-PR detection: chains where `B.baseRefName == A.headRefName` (same repo) become a `Stack` (bottom→top); `detect(_:)` + `Index` (`stack(containing:)` / `isStacked` / `position(of:)`); `StackEngineChecks.run()` #if DEBUG @@ -235,7 +236,7 @@ There is **no XCTest target**. The testing surface is `#if DEBUG` self-check functions on pure types, all invoked from `AppDelegate.applicationDidFinishLaunching`: `InboxMuteEngine.runSelfChecks()`, `PRSearchFilter.runSelfChecks()`, `PRClassificationChecks.run()`, `AttentionPolicyChecks.run()`, `NotificationRoutingChecks.run()`, `StackEngineChecks.run()`, -`PanelBackdropChecks.run()`. Add new pure logic's +`PanelBackdropChecks.run()`, `PRListExportChecks.run()`. Add new pure logic's assertions to one of these (or a sibling enum in the same file) rather than introducing a test framework. Keeping the decision table pure — `PREvent.migratedPolicy(from:storedVersion:)`, `NotificationService.classify(...)` — is what makes it assertable at all. @@ -243,7 +244,7 @@ test framework. Keeping the decision table pure — `PREvent.migratedPolicy(from ### Write actions Write actions (Approve, Merge, Request Changes, Mark Ready, Convert to Draft, Close, Reopen) are gated by `settings.writeActionsEnabled` (default OFF). Each is a `WriteAction` case handled in `PRManager.performAction` (GraphQL mutation in `GitHubClient` → `recordWriteAction` telemetry → `presentActionFailure` on error → `triggerSingleRefresh`). Always show an `NSAlert` confirmation before calling the GitHub API — via `TriageDeckView.dispatchVerb` + `confirmCopy`. The two deliberate exceptions are the draft-state pair, Mark Ready and Convert to Draft, which fire immediately with a plain toast because each reverses the other. Close is menu-only and never gets a default key. -**Row menus.** Every row kind has a right-click menu. Deck rows AND Postponed rows share `rowContextMenu(for:)` — GitHub write verbs, triage verbs (Later ↔ Resume when `snoozeStore.isSnoozed`), view/open (plus Open on GitHub when `prOpenTarget == .linear`), copy (link, branch), then Close PR (destructive role) or Reopen PR for a closed-unmerged pin. Done rows use the smaller `doneRowContextMenu(for:)` (open, copy, Reopen for closed-unmerged). A new triage verb belongs in `TriageAction` + `handleTriageAction` + the relevant menu, so the right-click menu stays the discoverable home for every action. +**Row menus.** Every row kind has a right-click menu. Deck rows AND Postponed rows share `rowContextMenu(for:)` — GitHub write verbs, triage verbs (Later ↔ Resume when `snoozeStore.isSnoozed`), view/open (plus Open on GitHub when `prOpenTarget == .linear`), copy (link, branch), then Close PR (destructive role) or Reopen PR for a closed-unmerged pin. Done rows use the smaller `doneRowContextMenu(for:)` (open, copy, Reopen for closed-unmerged). Section headers (actionability groups and the Inbox role headers) reveal a copy button on hover — also a "Copy PR List" right-click item — that copies the section's PRs, stack members included, as a Markdown list via `PRListExport` (`TriageDeckView.copyPRList`; hover state lives in a per-header `HoverReveal` so it never re-renders the deck). A new triage verb belongs in `TriageAction` + `handleTriageAction` + the relevant menu, so the right-click menu stays the discoverable home for every action. ### Keyboard events in MenuBarExtra `.onKeyPress` requires macOS 14+ (deployment target is 13). A local `NSEvent` monitor only fires while the app is *active*, and clicking the menu bar icon does NOT activate an accessory app — so key nav silently died on click-open. Instead `TriageDeckView` embeds a zero-size first-responder `NSView` (`KeyCaptureView`) that overrides `keyDown`: it receives keys whenever the popover window is key, independent of app-active state. It also handles Esc-to-close (the first responder otherwise swallows the popover's built-in Esc dismissal). diff --git a/Mainline.xcodeproj/project.pbxproj b/Mainline.xcodeproj/project.pbxproj index 5d463ac..48c0953 100644 --- a/Mainline.xcodeproj/project.pbxproj +++ b/Mainline.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ HP000000000000000000000B1 /* HelpSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = HP000000000000000000000A1 /* HelpSettingsView.swift */; }; LG000000000000000000000B1 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LG000000000000000000000A1 /* LaunchAtLoginService.swift */; }; PS000000000000000000000B1 /* PRSearchFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = PS000000000000000000000A1 /* PRSearchFilter.swift */; }; + PX000000000000000000000B1 /* PRListExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = PX000000000000000000000A1 /* PRListExport.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -89,6 +90,7 @@ HP000000000000000000000A1 /* HelpSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpSettingsView.swift; sourceTree = ""; }; LG000000000000000000000A1 /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = ""; }; PS000000000000000000000A1 /* PRSearchFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRSearchFilter.swift; sourceTree = ""; }; + PX000000000000000000000A1 /* PRListExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PRListExport.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -158,6 +160,7 @@ IB000000000000000000000A1 /* InboxMuteEngine.swift */, LG000000000000000000000A1 /* LaunchAtLoginService.swift */, PS000000000000000000000A1 /* PRSearchFilter.swift */, + PX000000000000000000000A1 /* PRListExport.swift */, ); path = Services; sourceTree = ""; @@ -299,6 +302,7 @@ HP000000000000000000000B1 /* HelpSettingsView.swift in Sources */, LG000000000000000000000B1 /* LaunchAtLoginService.swift in Sources */, PS000000000000000000000B1 /* PRSearchFilter.swift in Sources */, + PX000000000000000000000B1 /* PRListExport.swift in Sources */, OT00000000000000000000B1 /* TelemetryService.swift in Sources */, OT00000000000000000000B2 /* TelemetryOptInBanner.swift in Sources */, OT00000000000000000000B3 /* TelemetryDetailsSheet.swift in Sources */, diff --git a/Mainline/Mainline/MainlineApp.swift b/Mainline/Mainline/MainlineApp.swift index d4a9890..b236056 100644 --- a/Mainline/Mainline/MainlineApp.swift +++ b/Mainline/Mainline/MainlineApp.swift @@ -88,6 +88,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent PollCarryForwardChecks.run() StackEngineChecks.run() PanelBackdropChecks.run() + PRListExportChecks.run() #endif // Listen for open-settings requests from MenuBarView diff --git a/Mainline/Mainline/Services/PRListExport.swift b/Mainline/Mainline/Services/PRListExport.swift new file mode 100644 index 0000000..bccd715 --- /dev/null +++ b/Mainline/Mainline/Services/PRListExport.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Pure formatter that turns a section's PRs into a Markdown list for the clipboard. +/// +/// The output is shaped to be pasted into an agent ("which of these are ready to +/// merge?"), so every PR carries its URL plus the state the deck already shows — +/// CI, review decision, draft, conflicts, unresolved threads, size, branch — as +/// plain words rather than icons. No I/O; `PRListExportChecks` asserts it. +enum PRListExport { + + /// - Parameters: + /// - heading: section path shown above the list, e.g. `"Your PRs › Needs attention"`. + /// - prs: the section's PRs, in display order. + static func markdown(heading: String, prs: [PRSnapshot]) -> String { + let noun = prs.count == 1 ? "PR" : "PRs" + var lines = ["## \(heading) (\(prs.count) \(noun))", ""] + for pr in prs { + lines.append("- [\(pr.repoFullName)#\(pr.number)](\(pr.htmlUrl)) \(pr.title)") + lines.append(" - \(details(pr).joined(separator: " · "))") + } + return lines.joined(separator: "\n") + } + + /// The per-PR status line, most decision-relevant facts first. + static func details(_ pr: PRSnapshot) -> [String] { + var parts: [String] = [] + if !pr.author.isEmpty { parts.append("author: @\(pr.author)") } + if pr.merged { + parts.append("merged") + } else if pr.closed { + parts.append("closed") + } else if pr.isDraft { + parts.append("draft") + } + parts.append("CI: \(ciLabel(pr.ciStatus))") + parts.append("review: \(reviewLabel(pr.reviewDecision))") + if pr.mergeable == false { parts.append("merge conflict") } + if pr.unresolvedThreadCount > 0 { + let noun = pr.unresolvedThreadCount == 1 ? "thread" : "threads" + parts.append("\(pr.unresolvedThreadCount) unresolved \(noun)") + } + parts.append("+\(pr.linesAdded) −\(pr.linesDeleted)") + if !pr.headRefName.isEmpty { + let base = pr.baseRefName.isEmpty ? "" : " → \(pr.baseRefName)" + parts.append("branch: `\(pr.headRefName)`\(base)") + } + return parts + } + + private static func ciLabel(_ status: CIStatus) -> String { + switch status { + case .success: return "passing" + case .failure: return "failing" + case .error: return "error" + case .pending: return "pending" + case .unknown: return "none" + } + } + + private static func reviewLabel(_ decision: ReviewDecision?) -> String { + switch decision { + case .approved: return "approved" + case .changesRequested: return "changes requested" + case .reviewRequired: return "review required" + case nil: return "none" + } + } +} + +#if DEBUG +enum PRListExportChecks { + static func run() { + let green = PRSnapshot( + nodeId: "a", number: 12, title: "feat: thing", htmlUrl: "https://github.com/o/r/pull/12", + repoFullName: "o/r", isDraft: false, state: "open", reviewDecision: .approved, + ciStatus: .success, reviewState: .approved, commentCount: 0, updatedAt: "", + author: "me", requestedReviewers: [], mergeable: true, + headRefName: "feat/thing", baseRefName: "main", linesAdded: 10, linesDeleted: 2 + ) + let blocked = PRSnapshot( + nodeId: "b", number: 7, title: "fix: other", htmlUrl: "https://github.com/o/r/pull/7", + repoFullName: "o/r", isDraft: true, state: "open", reviewDecision: .changesRequested, + ciStatus: .failure, reviewState: .changesRequested, commentCount: 0, updatedAt: "", + author: "", requestedReviewers: [], mergeable: false, + unresolvedThreadCount: 1 + ) + + let out = PRListExport.markdown(heading: "Your PRs › Needs attention", prs: [green, blocked]) + let lines = out.components(separatedBy: "\n") + assert(lines[0] == "## Your PRs › Needs attention (2 PRs)", "heading carries path + count") + assert(lines[2] == "- [o/r#12](https://github.com/o/r/pull/12) feat: thing", "row links repo#number to the URL") + assert(lines[3] == " - author: @me · CI: passing · review: approved · +10 −2 · branch: `feat/thing` → main", + "green PR details") + assert(lines[5] == " - draft · CI: failing · review: changes requested · merge conflict · 1 unresolved thread · +0 −0", + "blocked PR: draft/conflict/thread flags; empty author and branch omitted") + assert(PRListExport.markdown(heading: "X", prs: [green]).hasPrefix("## X (1 PR)"), "singular noun") + } +} +#endif diff --git a/Mainline/Mainline/Views/TriageDeckView.swift b/Mainline/Mainline/Views/TriageDeckView.swift index 5aec40d..3ff2dc1 100644 --- a/Mainline/Mainline/Views/TriageDeckView.swift +++ b/Mainline/Mainline/Views/TriageDeckView.swift @@ -636,7 +636,8 @@ struct TriageDeckView: View { sectionView( group: actionSection.group, prs: actionSection.prs, - expansion: inboxExpansionBinding(for: section.role, group: actionSection.group) + expansion: inboxExpansionBinding(for: section.role, group: actionSection.group), + headingPrefix: inboxRoleTitle(section.role) ) } } @@ -654,23 +655,32 @@ struct TriageDeckView: View { @ViewBuilder private func inboxRoleSectionHeader(for role: InboxRole, prs rolePRs: [PRSnapshot]) -> some View { - let title = role == .needsYourReview ? "Needs your review" : "Your PRs" - HStack(spacing: 6) { - Text(title) - .font(.caption) - .fontWeight(.bold) - .foregroundStyle(.primary) - Text("\(rolePRs.count)") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(.quaternary, in: Capsule()) - Spacer() + let title = inboxRoleTitle(role) + HoverReveal { hovering in + HStack(spacing: 6) { + Text(title) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.primary) + Text("\(rolePRs.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + Spacer() + copyListButton(heading: title, prs: rolePRs, visible: hovering) + } + .padding(.horizontal, RowMetrics.horizontalPadding) + .padding(.top, 12) + .padding(.bottom, 4) + .contentShape(Rectangle()) + .contextMenu { copyListMenuItem(heading: title, prs: rolePRs) } } - .padding(.horizontal, RowMetrics.horizontalPadding) - .padding(.top, 12) - .padding(.bottom, 4) + } + + private func inboxRoleTitle(_ role: InboxRole) -> String { + role == .needsYourReview ? "Needs your review" : "Your PRs" } /// The shared "Muted / low-priority (N)" group rendered at the bottom of the @@ -816,11 +826,15 @@ struct TriageDeckView: View { private func sectionView( group: ActionGroup, prs sectionPRs: [PRSnapshot], - expansion: Binding + expansion: Binding, + headingPrefix: String? = nil ) -> some View { + let copyHeading = headingPrefix.map { "\($0) › \(group.title)" } ?? group.title // Whole-header tap toggles the section (label + count + chevron), matching // the Needs-a-Human header. Replaces DisclosureGroup so tapping the text or - // count — not just the triangle — expands/collapses. + // count — not just the triangle — expands/collapses. The copy button is an + // overlay (not nested in the toggle's label) so its click doesn't also toggle. + HoverReveal { hovering in Button { // No withAnimation: animating the expand makes the body height change // every frame, which the GeometryReader/BodyHeightKey → .frame(height:) @@ -856,6 +870,13 @@ struct TriageDeckView: View { } .buttonStyle(.plain) .help(expansion.wrappedValue ? "Collapse \(group.title)" : "Expand \(group.title)") + .overlay(alignment: .trailing) { + // Sits just left of the chevron. + copyListButton(heading: copyHeading, prs: sectionPRs, visible: hovering) + .padding(.trailing, RowMetrics.horizontalPadding + 18) + } + .contextMenu { copyListMenuItem(heading: copyHeading, prs: sectionPRs) } + } if expansion.wrappedValue { sectionRows(group: group, prs: sectionPRs) @@ -2137,6 +2158,44 @@ struct TriageDeckView: View { manager.showInfoToast("Copied branch: \(branch)", symbol: "doc.on.doc") } + /// Copies a section's PRs to the clipboard as a Markdown list (`PRListExport`) — + /// URL, CI, review, draft/conflict/thread flags per PR — ready to paste into an + /// agent. Includes collapsed stack members, which the section always carries. + private func copyPRList(heading: String, prs listPRs: [PRSnapshot]) { + guard !listPRs.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(PRListExport.markdown(heading: heading, prs: listPRs), forType: .string) + TelemetryService.shared.recordTriageInteraction("copy_pr_list") + let noun = listPRs.count == 1 ? "PR" : "PRs" + manager.showInfoToast("Copied \(listPRs.count) \(noun): \(heading)", symbol: "doc.on.doc") + } + + /// Header copy button, revealed while the header is hovered. Kept in the layout + /// (opacity, not removal) so hovering doesn't shift the header's contents. + private func copyListButton(heading: String, prs listPRs: [PRSnapshot], visible: Bool) -> some View { + Button { + copyPRList(heading: heading, prs: listPRs) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: 18, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Copy these \(listPRs.count) PRs as a Markdown list") + .opacity(visible ? 1 : 0) + .allowsHitTesting(visible) + } + + @ViewBuilder + private func copyListMenuItem(heading: String, prs listPRs: [PRSnapshot]) -> some View { + Button("Copy PR List (\(listPRs.count))") { + copyPRList(heading: heading, prs: listPRs) + } + } + /// Copies the PR's GitHub URL to the system clipboard and confirms with a toast. /// Always the GitHub page (`htmlUrl`), whatever the configured open target, so /// the copied link works for anyone it is pasted to. Silent no-op when empty. @@ -2738,3 +2797,14 @@ private struct KeyCaptureView: NSViewRepresentable { } } } + +/// Tracks hover for one header in its own `@State`, so hovering a section header +/// re-renders just that header rather than the whole deck. +private struct HoverReveal: View { + @ViewBuilder let content: (Bool) -> Content + @State private var hovering = false + + var body: some View { + content(hovering).onHover { hovering = $0 } + } +} From 1b44a26904e12da15bcc82e9a2335592cbfeb502 Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 16:20:33 +0200 Subject: [PATCH 2/6] feat(deck): copy just the PR list, without a heading --- Mainline/Mainline/Services/PRListExport.swift | 21 ++++++++----------- Mainline/Mainline/Views/TriageDeckView.swift | 2 +- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Mainline/Mainline/Services/PRListExport.swift b/Mainline/Mainline/Services/PRListExport.swift index bccd715..3465d1e 100644 --- a/Mainline/Mainline/Services/PRListExport.swift +++ b/Mainline/Mainline/Services/PRListExport.swift @@ -8,12 +8,10 @@ import Foundation /// plain words rather than icons. No I/O; `PRListExportChecks` asserts it. enum PRListExport { - /// - Parameters: - /// - heading: section path shown above the list, e.g. `"Your PRs › Needs attention"`. - /// - prs: the section's PRs, in display order. - static func markdown(heading: String, prs: [PRSnapshot]) -> String { - let noun = prs.count == 1 ? "PR" : "PRs" - var lines = ["## \(heading) (\(prs.count) \(noun))", ""] + /// Just the list, no heading — the user pastes it under their own prompt. + /// - Parameter prs: the section's PRs, in display order. + static func markdown(prs: [PRSnapshot]) -> String { + var lines: [String] = [] for pr in prs { lines.append("- [\(pr.repoFullName)#\(pr.number)](\(pr.htmlUrl)) \(pr.title)") lines.append(" - \(details(pr).joined(separator: " · "))") @@ -85,15 +83,14 @@ enum PRListExportChecks { unresolvedThreadCount: 1 ) - let out = PRListExport.markdown(heading: "Your PRs › Needs attention", prs: [green, blocked]) + let out = PRListExport.markdown(prs: [green, blocked]) let lines = out.components(separatedBy: "\n") - assert(lines[0] == "## Your PRs › Needs attention (2 PRs)", "heading carries path + count") - assert(lines[2] == "- [o/r#12](https://github.com/o/r/pull/12) feat: thing", "row links repo#number to the URL") - assert(lines[3] == " - author: @me · CI: passing · review: approved · +10 −2 · branch: `feat/thing` → main", + assert(lines.count == 4, "two lines per PR, no heading") + assert(lines[0] == "- [o/r#12](https://github.com/o/r/pull/12) feat: thing", "row links repo#number to the URL") + assert(lines[1] == " - author: @me · CI: passing · review: approved · +10 −2 · branch: `feat/thing` → main", "green PR details") - assert(lines[5] == " - draft · CI: failing · review: changes requested · merge conflict · 1 unresolved thread · +0 −0", + assert(lines[3] == " - draft · CI: failing · review: changes requested · merge conflict · 1 unresolved thread · +0 −0", "blocked PR: draft/conflict/thread flags; empty author and branch omitted") - assert(PRListExport.markdown(heading: "X", prs: [green]).hasPrefix("## X (1 PR)"), "singular noun") } } #endif diff --git a/Mainline/Mainline/Views/TriageDeckView.swift b/Mainline/Mainline/Views/TriageDeckView.swift index 3ff2dc1..1d9f779 100644 --- a/Mainline/Mainline/Views/TriageDeckView.swift +++ b/Mainline/Mainline/Views/TriageDeckView.swift @@ -2165,7 +2165,7 @@ struct TriageDeckView: View { guard !listPRs.isEmpty else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() - pasteboard.setString(PRListExport.markdown(heading: heading, prs: listPRs), forType: .string) + pasteboard.setString(PRListExport.markdown(prs: listPRs), forType: .string) TelemetryService.shared.recordTriageInteraction("copy_pr_list") let noun = listPRs.count == 1 ? "PR" : "PRs" manager.showInfoToast("Copied \(listPRs.count) \(noun): \(heading)", symbol: "doc.on.doc") From dfbc0558028990a57ec10f0a5862be4928767882 Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 16:24:11 +0200 Subject: [PATCH 3/6] docs(telemetry): list the copy_pr_list interaction value --- Mainline/Mainline/Services/TelemetryService.swift | 2 +- docs/telemetry.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mainline/Mainline/Services/TelemetryService.swift b/Mainline/Mainline/Services/TelemetryService.swift index 4112629..b6a4815 100644 --- a/Mainline/Mainline/Services/TelemetryService.swift +++ b/Mainline/Mainline/Services/TelemetryService.swift @@ -585,7 +585,7 @@ final class TelemetryService { /// "open_in_browser" | "diff_preview" | "copy_branch" | "open_preview" | "tab_switch" | /// "scope_filter_change" | "toggle_drafts" | "inbox_mute" | "inbox_unmute" | /// "undo" | "refresh" | "multi_select_toggle" | "mark_ready" | "for_me_filter_change" | - /// "convert_to_draft" | "copy_link" + /// "convert_to_draft" | "copy_link" | "copy_pr_list" func recordTriageInteraction(_ interaction: String) { guard MainlineSettings.shared.telemetryEnabled else { return } ensureSetup() diff --git a/docs/telemetry.md b/docs/telemetry.md index dcad9cf..ffaf18c 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -141,7 +141,7 @@ All attributes use **bounded, low-cardinality values** — never raw user data. | `write.action` | `"approve"`, `"merge"`, `"request_changes"`, `"mark_ready"`, `"convert_to_draft"`, `"close"`, `"reopen"` | | `write.result` | `"success"`, `"failure"` | | `write.merge_method` | `"squash"`, `"merge"`, `"rebase"`, `"auto"` (from resolved preference, never PR title/branch) | -| `interaction.type` | `"snooze"`, `"unsnooze"`, `"mark_seen"`, `"dismiss"`, `"open_in_browser"`, `"diff_preview"`, `"copy_branch"`, `"open_preview"`, `"tab_switch"`, `"scope_filter_change"`, `"toggle_drafts"`, `"inbox_mute"`, `"inbox_unmute"`, `"undo"`, `"refresh"`, `"multi_select_toggle"`, `"mark_ready"`, `"convert_to_draft"`, `"copy_link"` | +| `interaction.type` | `"snooze"`, `"unsnooze"`, `"mark_seen"`, `"dismiss"`, `"open_in_browser"`, `"diff_preview"`, `"copy_branch"`, `"open_preview"`, `"tab_switch"`, `"scope_filter_change"`, `"toggle_drafts"`, `"inbox_mute"`, `"inbox_unmute"`, `"undo"`, `"refresh"`, `"multi_select_toggle"`, `"mark_ready"`, `"convert_to_draft"`, `"copy_link"`, `"copy_pr_list"` | | `notification.event_type` | `"newPR"`, `"readyForReview"`, `"ciChanged"`, `"reviewComment"` | | `notification.attention_level` | `"notify"`, `"quiet"` | | `attention.event` | `PREvent` raw values (e.g. `"reviewRequested"`, `"ciFailedOnMyPR"`) — never PR content | From 334001dddbe0d0db106cef9a4ef800626770cf78 Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 16:24:12 +0200 Subject: [PATCH 4/6] fix(deck): singular copy-list tooltip for a one-PR section --- Mainline/Mainline/Views/TriageDeckView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mainline/Mainline/Views/TriageDeckView.swift b/Mainline/Mainline/Views/TriageDeckView.swift index 1d9f779..3fcd73f 100644 --- a/Mainline/Mainline/Views/TriageDeckView.swift +++ b/Mainline/Mainline/Views/TriageDeckView.swift @@ -2184,7 +2184,7 @@ struct TriageDeckView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help("Copy these \(listPRs.count) PRs as a Markdown list") + .help("Copy \(listPRs.count == 1 ? "this PR" : "these \(listPRs.count) PRs") as a Markdown list") .opacity(visible ? 1 : 0) .allowsHitTesting(visible) } From cd987f007e45126a048e8433924691bbe1f302a6 Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 16:31:48 +0200 Subject: [PATCH 5/6] fix(deck): copy a role header's PRs in on-screen order --- Mainline/Mainline/Views/TriageDeckView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Mainline/Mainline/Views/TriageDeckView.swift b/Mainline/Mainline/Views/TriageDeckView.swift index 3fcd73f..143c43d 100644 --- a/Mainline/Mainline/Views/TriageDeckView.swift +++ b/Mainline/Mainline/Views/TriageDeckView.swift @@ -631,7 +631,9 @@ struct TriageDeckView: View { private var inboxList: some View { VStack(alignment: .leading, spacing: 0) { ForEach(inboxSections, id: \.role.rawValue) { section in - inboxRoleSectionHeader(for: section.role, prs: inboxRolePRs(for: section.role)) + // Display order (pinned first, then each actionability group), so a copied + // list matches the screen. + inboxRoleSectionHeader(for: section.role, prs: section.actionSections.flatMap(\.prs)) ForEach(section.actionSections, id: \.group) { actionSection in sectionView( group: actionSection.group, From 0f762e77d20b9078c4218eff7c9a63b993415576 Mon Sep 17 00:00:00 2001 From: mthines Date: Mon, 19 Oct 2026 16:31:49 +0200 Subject: [PATCH 6/6] docs: document copying a section's PRs --- CLAUDE.md | 2 +- README.md | 1 + docs/keyboard.md | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f57082..a617730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ Mainline/Mainline/ ← Source root ├── InboxSettingsView.swift ← Inbox noise-filter settings: Review Readiness (four reviewer "not ready → Waiting" toggles: conflict / failing CI / unresolved threads / approved-by-me, all default ON), mute patterns, muteBotAuthors toggle, per-org Review Focus (org sub-blocks nested INSIDE the one Review Focus card via `orgFocusBlock`, each with a Remove button; authors+teams keyed by lowercased org, derived from `manager.knownOrgs` + saved config + an add-org field), muteLabels ├── KeyboardShortcutsView.swift ← Configurable deck/peek shortcuts UI: per-action `InAppShortcutRecorder`, clash detection, Reset All button ├── MenuBarIconView.swift ← Dynamic badge: MenuBarBadge enum → SF Symbol + tint - ├── TriageDeckView.swift ← Keyboard triage (J/K/Space/↵/E/M/T/R/S/N/X/V/D/Q/C/P/F/⌘Z) + TriageAction enum + per-row context menus (`rowContextMenu` for deck + Postponed rows, `doneRowContextMenu` for Done rows; see "Write actions") + first-responder KeyCaptureView; deck keys are user-configurable via settings.shortcutBindings; Inbox mode: role sections (yourPRs / needsYourReview) + collapsed Muted group; Q toggles per-PR manual mute (settings.inboxMuteOverrides); T marks draft PR ready for review (draft-only, write-gated, plain toast); P pins/unpins the focused PR (floats it to the top of its group, `settings.pinnedNodeIds`); F opens the flat search-results view (`searchMode`) + ├── TriageDeckView.swift ← Keyboard triage (J/K/Space/↵/E/M/T/R/S/N/X/V/D/Q/C/P/F/⌘Z) + TriageAction enum + per-row context menus (`rowContextMenu` for deck + Postponed rows, `doneRowContextMenu` for Done rows; see "Write actions") + hover-revealed section-header copy button (`copyPRList` → `PRListExport`) + first-responder KeyCaptureView; deck keys are user-configurable via settings.shortcutBindings; Inbox mode: role sections (yourPRs / needsYourReview) + collapsed Muted group; Q toggles per-PR manual mute (settings.inboxMuteOverrides); T marks draft PR ready for review (draft-only, write-gated, plain toast); P pins/unpins the focused PR (floats it to the top of its group, `settings.pinnedNodeIds`); F opens the flat search-results view (`searchMode`) ├── PRPeekView.swift ← Space peek: instant glance card + async files list ├── UndoToastView.swift ← Batched undo toast stack ├── TelemetryOptInBanner.swift← Dismissable Privacy-pane opt-in banner (consent-versioned) diff --git a/README.md b/README.md index 62cbc9b..057aa78 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Not relevant, hover and press `Q`. For later, press `S`. - **Keyboard triage** — drive the whole panel from the keyboard: `J`/`K` to move, `Space` to peek, `↩` to open, `M` to merge, `S` to postpone, `E` to open the preview, `N` to mark seen, `X` to dismiss, `V` for multi-select, `D` to toggle drafts, `Q` to mute (Inbox), `R` to refresh, `⌘Z` to undo. Every key is rebindable in Settings → Keyboard. - **Peek** — `Space` opens a glance card with the PR's changed-files list. - **Row menu** — right-click any PR for every action, including approve, request changes, close, convert to draft, and copy link. +- **Copy a section** — hover a section header to copy its PRs as a Markdown list (links + CI/review state), ready to paste into an agent. - **Global shortcut** — open Mainline from any app with a system-wide hotkey (default ⇧⌃ + the key left of `1`; customizable in Settings). - **Scope filter** — narrow the panel and badge to a single org or repo. diff --git a/docs/keyboard.md b/docs/keyboard.md index 5dcf17d..1a848dc 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -87,6 +87,11 @@ The "open" item follows your open target, so it reads **Open in Linear** when Li Postponed rows have the same menu, with **Resume** in place of **Later**. Rows in **Done** have a shorter menu: open, open on GitHub, copy link, copy branch name, and — for a PR closed without merging — reopen. +## Copying a section + +Hover a section header (**Needs attention**, **Waiting**, …) or a role header (**Your PRs**, **Needs your review**) and a copy button appears; right-clicking the header offers **Copy PR List** too. +It copies the section's PRs, in on-screen order and including collapsed stack members, as a Markdown list — each PR's link and title plus its author, draft state, CI, review decision, conflicts, unresolved threads, size, and branch — ready to paste into an agent. + ## Global shortcut A system-wide hotkey opens the Mainline panel from any app.