[object Object]

← back to Exo

feat: add user context prompt and GitHub issue option to macOS bug report (#1544)

7312c535b4938fe61bd1913b2ac232b3eae54834 · 2026-02-22 07:37:43 -0800 · Alex Cheema

## Summary

- When clicking "Send Bug Report" in the macOS app, users are now
prompted with "What's the issue? (optional)" before the diagnostic
upload begins
- The user's description is included in the uploaded report JSON
(`user_description` field)
- After successful upload, a "Create GitHub Issue" button opens the
browser to `github.com/exo-explore/exo/issues/new` pre-filled with the
user's description, macOS version, and EXO version

## Changes

- **`ContentView.swift`**: Replaced simple button with multi-phase
inline UI (idle → prompting → sending → success/failure). Added
`openGitHubIssue()` helper using `URLComponents` for pre-filled GitHub
issue URLs.
- **`BugReportService.swift`**: Added `userDescription` parameter to
`sendReport()` and `makeReportJson()`, included in the JSON payload when
non-empty.
- Removed the previous `BugReportModal.svelte` approach (the feature
belongs in the macOS app, not the dashboard).

## Test plan

- [ ] Build the Xcode project and run the macOS app
- [ ] Click "Send Bug Report" → verify text prompt appears
- [ ] Type a description, click Send → verify upload succeeds and
"Create GitHub Issue" button appears
- [ ] Click "Create GitHub Issue" → verify browser opens with pre-filled
template
- [ ] Test Cancel returns to idle, test empty description still works
- [ ] Test failure case (e.g. with exo stopped) shows error and dismiss

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 7312c535b4938fe61bd1913b2ac232b3eae54834
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Sun Feb 22 07:37:43 2026 -0800

    feat: add user context prompt and GitHub issue option to macOS bug report (#1544)
    
    ## Summary
    
    - When clicking "Send Bug Report" in the macOS app, users are now
    prompted with "What's the issue? (optional)" before the diagnostic
    upload begins
    - The user's description is included in the uploaded report JSON
    (`user_description` field)
    - After successful upload, a "Create GitHub Issue" button opens the
    browser to `github.com/exo-explore/exo/issues/new` pre-filled with the
    user's description, macOS version, and EXO version
    
    ## Changes
    
    - **`ContentView.swift`**: Replaced simple button with multi-phase
    inline UI (idle → prompting → sending → success/failure). Added
    `openGitHubIssue()` helper using `URLComponents` for pre-filled GitHub
    issue URLs.
    - **`BugReportService.swift`**: Added `userDescription` parameter to
    `sendReport()` and `makeReportJson()`, included in the JSON payload when
    non-empty.
    - Removed the previous `BugReportModal.svelte` approach (the feature
    belongs in the macOS app, not the dashboard).
    
    ## Test plan
    
    - [ ] Build the Xcode project and run the macOS app
    - [ ] Click "Send Bug Report" → verify text prompt appears
    - [ ] Type a description, click Send → verify upload succeeds and
    "Create GitHub Issue" button appears
    - [ ] Click "Create GitHub Issue" → verify browser opens with pre-filled
    template
    - [ ] Test Cancel returns to idle, test empty description still works
    - [ ] Test failure case (e.g. with exo stopped) shows error and dismiss
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
 app/EXO/EXO/ContentView.swift               | 185 +++++++++++++++++++++++-----
 app/EXO/EXO/Services/BugReportService.swift |  12 +-
 2 files changed, 164 insertions(+), 33 deletions(-)

diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index 688fba5b..4bd620ea 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -21,8 +21,15 @@ struct ContentView: View {
     @State private var showAllInstances = false
     @State private var showAdvanced = false
     @State private var showDebugInfo = false
-    @State private var bugReportInFlight = false
-    @State private var bugReportMessage: String?
+    private enum BugReportPhase: Equatable {
+        case idle
+        case prompting
+        case sending(String)
+        case success(String)
+        case failure(String)
+    }
+    @State private var bugReportPhase: BugReportPhase = .idle
+    @State private var bugReportUserDescription: String = ""
     @State private var uninstallInProgress = false
     @State private var pendingNamespace: String = ""
     @State private var pendingHFToken: String = ""
@@ -611,39 +618,115 @@ struct ContentView: View {
     }
 
     private var sendBugReportButton: some View {
-        VStack(alignment: .leading, spacing: 4) {
-            Button {
-                Task {
-                    await sendBugReport()
+        VStack(alignment: .leading, spacing: 6) {
+            switch bugReportPhase {
+            case .idle:
+                Button {
+                    bugReportPhase = .prompting
+                    bugReportUserDescription = ""
+                } label: {
+                    HStack {
+                        Text("Send Bug Report")
+                            .font(.caption)
+                            .fontWeight(.semibold)
+                        Spacer()
+                    }
+                    .padding(.vertical, 6)
+                    .padding(.horizontal, 8)
+                    .background(
+                        RoundedRectangle(cornerRadius: 6)
+                            .fill(Color.accentColor.opacity(0.12))
+                    )
                 }
-            } label: {
-                HStack {
-                    if bugReportInFlight {
-                        ProgressView()
-                            .scaleEffect(0.6)
+                .buttonStyle(.plain)
+
+            case .prompting:
+                VStack(alignment: .leading, spacing: 6) {
+                    Text("What's the issue? (optional)")
+                        .font(.caption2)
+                        .foregroundColor(.secondary)
+                    TextEditor(text: $bugReportUserDescription)
+                        .font(.caption2)
+                        .frame(height: 60)
+                        .overlay(
+                            RoundedRectangle(cornerRadius: 4)
+                                .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
+                        )
+                    HStack(spacing: 8) {
+                        Button("Send") {
+                            Task {
+                                await sendBugReport()
+                            }
+                        }
+                        .font(.caption2)
+                        .buttonStyle(.borderedProminent)
+                        .controlSize(.small)
+                        Button("Cancel") {
+                            bugReportPhase = .idle
+                        }
+                        .font(.caption2)
+                        .buttonStyle(.bordered)
+                        .controlSize(.small)
                     }
-                    Text("Send Bug Report")
-                        .font(.caption)
-                        .fontWeight(.semibold)
-                    Spacer()
                 }
-                .padding(.vertical, 6)
-                .padding(.horizontal, 8)
+                .padding(8)
                 .background(
                     RoundedRectangle(cornerRadius: 6)
-                        .fill(Color.accentColor.opacity(0.12))
+                        .fill(Color.accentColor.opacity(0.06))
                 )
-            }
-            .buttonStyle(.plain)
-            .disabled(bugReportInFlight)
 
-            if let message = bugReportMessage {
-                Text(message)
+            case .sending(let message):
+                HStack(spacing: 6) {
+                    ProgressView()
+                        .scaleEffect(0.6)
+                    Text(message)
+                        .font(.caption2)
+                        .foregroundColor(.secondary)
+                }
+
+            case .success(let message):
+                VStack(alignment: .leading, spacing: 6) {
+                    Text(message)
+                        .font(.caption2)
+                        .foregroundColor(.secondary)
+                        .fixedSize(horizontal: false, vertical: true)
+                    Button {
+                        openGitHubIssue()
+                    } label: {
+                        HStack(spacing: 4) {
+                            Image(systemName: "arrow.up.right.square")
+                                .imageScale(.small)
+                            Text("Create GitHub Issue")
+                                .font(.caption2)
+                        }
+                    }
+                    .buttonStyle(.bordered)
+                    .controlSize(.small)
+                    Button("Done") {
+                        bugReportPhase = .idle
+                        bugReportUserDescription = ""
+                    }
                     .font(.caption2)
+                    .buttonStyle(.plain)
                     .foregroundColor(.secondary)
-                    .fixedSize(horizontal: false, vertical: true)
+                }
+
+            case .failure(let message):
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(message)
+                        .font(.caption2)
+                        .foregroundColor(.red)
+                        .fixedSize(horizontal: false, vertical: true)
+                    Button("Dismiss") {
+                        bugReportPhase = .idle
+                    }
+                    .font(.caption2)
+                    .buttonStyle(.plain)
+                    .foregroundColor(.secondary)
+                }
             }
         }
+        .animation(.easeInOut(duration: 0.2), value: bugReportPhase)
     }
 
     private var processToggleBinding: Binding<Bool> {
@@ -687,16 +770,58 @@ struct ContentView: View {
     }
 
     private func sendBugReport() async {
-        bugReportInFlight = true
-        bugReportMessage = "Collecting logs..."
+        bugReportPhase = .sending("Collecting logs...")
         let service = BugReportService()
+        let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
         do {
-            let outcome = try await service.sendReport(isManual: true)
-            bugReportMessage = outcome.message
+            let outcome = try await service.sendReport(
+                isManual: true,
+                userDescription: description.isEmpty ? nil : description
+            )
+            if outcome.success {
+                bugReportPhase = .success(outcome.message)
+            } else {
+                bugReportPhase = .failure(outcome.message)
+            }
         } catch {
-            bugReportMessage = error.localizedDescription
+            bugReportPhase = .failure(error.localizedDescription)
+        }
+    }
+
+    private func openGitHubIssue() {
+        let description = bugReportUserDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+
+        var bodyParts: [String] = []
+        bodyParts.append("## Describe the bug")
+        bodyParts.append("")
+        if !description.isEmpty {
+            bodyParts.append(description)
+        } else {
+            bodyParts.append("A clear and concise description of what the bug is.")
+        }
+        bodyParts.append("")
+        bodyParts.append("## Environment")
+        bodyParts.append("")
+        bodyParts.append("- macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
+        bodyParts.append("- EXO Version: \(buildTag) (\(buildCommit))")
+        bodyParts.append("")
+        bodyParts.append("## Additional context")
+        bodyParts.append("")
+        bodyParts.append("A bug report with diagnostic logs was submitted via the app.")
+
+        let body = bodyParts.joined(separator: "\n")
+
+        var components = URLComponents(string: "https://github.com/exo-explore/exo/issues/new")!
+        components.queryItems = [
+            URLQueryItem(name: "template", value: "bug_report.md"),
+            URLQueryItem(name: "title", value: "[BUG] "),
+            URLQueryItem(name: "body", value: body),
+            URLQueryItem(name: "labels", value: "bug"),
+        ]
+
+        if let url = components.url {
+            NSWorkspace.shared.open(url)
         }
-        bugReportInFlight = false
     }
 
     private func showUninstallConfirmationAlert() {
diff --git a/app/EXO/EXO/Services/BugReportService.swift b/app/EXO/EXO/Services/BugReportService.swift
index 5c2cec95..14e5b885 100644
--- a/app/EXO/EXO/Services/BugReportService.swift
+++ b/app/EXO/EXO/Services/BugReportService.swift
@@ -38,7 +38,8 @@ struct BugReportService {
     func sendReport(
         baseURL: URL = URL(string: "http://127.0.0.1:52415")!,
         now: Date = Date(),
-        isManual: Bool = false
+        isManual: Bool = false,
+        userDescription: String? = nil
     ) async throws -> BugReportOutcome {
         let timestamp = Self.runTimestampString(now)
         let dayPrefix = Self.dayPrefixString(now)
@@ -60,7 +61,8 @@ struct BugReportService {
             ifconfig: ifconfigText,
             debugInfo: debugInfo,
             isManual: isManual,
-            clusterTbBridgeStatus: clusterTbBridgeStatus
+            clusterTbBridgeStatus: clusterTbBridgeStatus,
+            userDescription: userDescription
         )
 
         let eventLogFiles = readAllEventLogs()
@@ -306,7 +308,8 @@ struct BugReportService {
         ifconfig: String,
         debugInfo: DebugInfo,
         isManual: Bool,
-        clusterTbBridgeStatus: [[String: Any]]?
+        clusterTbBridgeStatus: [[String: Any]]?,
+        userDescription: String? = nil
     ) -> Data? {
         let system = readSystemMetadata()
         let exo = readExoMetadata()
@@ -323,6 +326,9 @@ struct BugReportService {
         if let tbStatus = clusterTbBridgeStatus {
             payload["cluster_thunderbolt_bridge"] = tbStatus
         }
+        if let desc = userDescription, !desc.isEmpty {
+            payload["user_description"] = desc
+        }
         return try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
     }
 

← 18717023 chore: remove deprecated MlxIbv dashboard references (#1584)  ·  back to Exo  ·  fix: raise error when MlxJaccl requested without RDMA cycles a4c2aa2b →